diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fcf02f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +__pycache__/ +*.py[cod] +*.log +*.db +*.sqlite3 +.env +.venv +.cache +.mypy_cache +.pytest_cache +dist/ +build/ +*.egg-info/ +.idea/ +.vscode/ +coverage.xml +.coverage + diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa1459c --- /dev/null +++ b/README.md @@ -0,0 +1,145 @@ +# βš”οΈ Falyx + +**Falyx** is a resilient, introspectable CLI framework for building robust, asynchronous command-line workflows with: + +- βœ… Modular action chaining and rollback +- πŸ” Built-in retry handling +- βš™οΈ Full lifecycle hooks (before, after, success, error, teardown) +- πŸ“Š Execution tracing, logging, and introspection +- πŸ§™β€β™‚οΈ Async-first design with Process support +- 🧩 Extensible CLI menus and customizable output + +> Built for developers who value *clarity*, *resilience*, and *visibility* in their terminal workflows. + +--- + +## ✨ Why Falyx? + +Modern CLI tools deserve the same resilience as production systems. Falyx makes it easy to: + +- Compose workflows using `Action`, `ChainedAction`, or `ActionGroup` +- Inject the result of one step into the next (`last_result`) +- Handle flaky operations with retries and exponential backoff +- Roll back safely on failure with structured undo logic +- Add observability with execution timing, result tracking, and hooks +- Run in both interactive or headless (scriptable) modes +- Customize output with Rich `Table`s (grouping, theming, etc.) + +--- + +## πŸ”§ Installation + +```bash +pip install falyx +``` + +> Or install from source: + +```bash +git clone https://github.com/yourname/falyx.git +cd falyx +poetry install +``` + +--- + +## ⚑ Quick Example + +```python +from falyx import Action, ChainedAction, Menu +from falyx.hooks import RetryHandler, log_success + +async def flaky_step(): + import random, asyncio + await asyncio.sleep(0.2) + if random.random() < 0.8: + raise RuntimeError("Random failure!") + return "ok" + +retry = RetryHandler() + +step1 = Action("Step 1", flaky_step) +step1.hooks.register("on_error", retry.retry_on_error) + +step2 = Action("Step 2", flaky_step) +step2.hooks.register("on_error", retry.retry_on_error) + +chain = ChainedAction("My Pipeline", [step1, step2]) +chain.hooks.register("on_success", log_success) + +menu = Menu(title="πŸš€ Falyx Demo") +menu.add_command("R", "Run My Pipeline", chain) + +if __name__ == "__main__": + import asyncio + asyncio.run(menu.run()) +``` + +--- + +## πŸ“¦ Core Features + +- βœ… Async-native `Action`, `ChainedAction`, `ActionGroup` +- πŸ” Retry policies + exponential backoff +- β›“ Rollbacks on chained failures +- πŸŽ›οΈ Headless or interactive CLI with argparse and prompt_toolkit +- πŸ“Š Built-in execution registry, result tracking, and timing +- 🧠 Supports `ProcessAction` for CPU-bound workloads +- 🧩 Custom `Table` rendering for CLI menu views +- πŸ” Hook lifecycle: `before`, `on_success`, `on_error`, `after`, `on_teardown` + +--- + +## πŸ” Execution Trace + +```bash +[2025-04-14 10:33:22] DEBUG [Step 1] βš™ flaky_step() +[2025-04-14 10:33:22] INFO [Step 1] πŸ” Retrying (1/3) in 1.0s... +[2025-04-14 10:33:23] DEBUG [Step 1] βœ… Success | Result: ok +[2025-04-14 10:33:23] DEBUG [My Pipeline] βœ… Result: ['ok', 'ok'] +``` + +--- + +## 🧩 Components + +| Component | Purpose | +|------------------|--------------------------------------------------------| +| `Action` | Single async task with hook + result injection support | +| `ChainedAction` | Sequential task runner with rollback | +| `ActionGroup` | Parallel runner for independent tasks | +| `ProcessAction` | CPU-bound task in a separate process (multiprocessing) | +| `Menu` | CLI runner with toggleable prompt or headless mode | +| `ExecutionContext`| Captures metadata per execution | +| `HookManager` | Lifecycle hook registration engine | + +--- + +## 🧠 Design Philosophy + +> β€œLike a phalanx: organized, resilient, and reliable.” + +Falyx is designed for developers who don’t just want CLI tools to run β€” they want them to **fail meaningfully**, **recover gracefully**, and **log clearly**. + +--- + +## πŸ›£οΈ Roadmap + +- [ ] Retry policy DSL (e.g., `max_retries=3, backoff="exponential"`) +- [ ] Metrics export (Prometheus-style) +- [ ] Plugin system for menu extensions +- [ ] Native support for structured logs + log forwarding +- [ ] Web UI for interactive execution history (maybe!) + +--- + +## πŸ§‘β€πŸ’Ό License + +MIT β€” use it, fork it, improve it. Attribution appreciated! + +--- + +## 🌐 falyx.dev β€” **reliable actions, resilient flows** + +--- + diff --git a/falyx/__init__.py b/falyx/__init__.py new file mode 100644 index 0000000..b9d6a55 --- /dev/null +++ b/falyx/__init__.py @@ -0,0 +1,23 @@ +import logging + +from .action import Action, ActionGroup, ChainedAction, ProcessAction +from .command import Command +from .context import ExecutionContext, ResultsContext +from .execution_registry import ExecutionRegistry +from .falyx import Falyx + +logger = logging.getLogger("falyx") + +__version__ = "0.1.0" + +__all__ = [ + "Action", + "ChainedAction", + "ActionGroup", + "ProcessAction", + "Falyx", + "Command", + "ExecutionContext", + "ResultsContext", + "ExecutionRegistry", +] diff --git a/falyx/__main__.py b/falyx/__main__.py new file mode 100644 index 0000000..12e9e6e --- /dev/null +++ b/falyx/__main__.py @@ -0,0 +1,41 @@ +# falyx/__main__.py + +import asyncio +import logging + +from falyx.action import Action +from falyx.falyx import Falyx + + +def build_falyx() -> Falyx: + """Build and return a Falyx instance with all your commands.""" + app = Falyx(title="πŸš€ Falyx CLI") + + # Example commands + app.add_command( + key="B", + description="Build project", + action=Action("Build", lambda: print("πŸ“¦ Building...")), + tags=["build"] + ) + + app.add_command( + key="T", + description="Run tests", + action=Action("Test", lambda: print("πŸ§ͺ Running tests...")), + tags=["test"] + ) + + app.add_command( + key="D", + description="Deploy project", + action=Action("Deploy", lambda: print("πŸš€ Deploying...")), + tags=["deploy"] + ) + + return app + +if __name__ == "__main__": + logging.basicConfig(level=logging.WARNING) + falyx = build_falyx() + asyncio.run(falyx.cli()) diff --git a/falyx/action.py b/falyx/action.py new file mode 100644 index 0000000..3188339 --- /dev/null +++ b/falyx/action.py @@ -0,0 +1,441 @@ +"""action.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 +""" +from __future__ import annotations + +import asyncio +import random +from abc import ABC, abstractmethod +from concurrent.futures import ProcessPoolExecutor +from functools import partial +from typing import Any, Callable + +from rich.console import Console +from rich.tree import Tree + +from falyx.context import ExecutionContext, ResultsContext +from falyx.debug import register_debug_hooks +from falyx.execution_registry import ExecutionRegistry as er +from falyx.hook_manager import HookManager, HookType +from falyx.themes.colors import OneColors +from falyx.utils import ensure_async, logger + +console = Console() + + +class BaseAction(ABC): + """ + Base class for actions. Actions can be simple functions or more + complex actions like `ChainedAction` or `ActionGroup`. They can also + be run independently or as part of Menu. + + 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'). + """ + def __init__( + self, + name: str, + hooks: HookManager | None = None, + inject_last_result: bool = False, + inject_last_result_as: str = "last_result", + logging_hooks: bool = False, + ) -> None: + self.name = name + self.hooks = hooks or HookManager() + self.is_retryable: bool = False + self.results_context: ResultsContext | None = None + self.inject_last_result: bool = inject_last_result + self.inject_last_result_as: str = inject_last_result_as + + if logging_hooks: + register_debug_hooks(self.hooks) + + async def __call__(self, *args, **kwargs) -> Any: + return await self._run(*args, **kwargs) + + @abstractmethod + async def _run(self, *args, **kwargs) -> Any: + raise NotImplementedError("_run must be implemented by subclasses") + + @abstractmethod + async def preview(self, parent: Tree | None = None): + raise NotImplementedError("preview must be implemented by subclasses") + + def set_results_context(self, results_context: ResultsContext): + self.results_context = results_context + + def prepare_for_chain(self, results_context: ResultsContext) -> BaseAction: + """ + Prepare the action specifically for sequential (ChainedAction) execution. + Can be overridden for chain-specific logic. + """ + self.set_results_context(results_context) + return self + + def prepare_for_group(self, results_context: ResultsContext) -> BaseAction: + """ + Prepare the action specifically for parallel (ActionGroup) execution. + Can be overridden for group-specific logic. + """ + self.set_results_context(results_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: + 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() + return kwargs + + def register_hooks_recursively(self, hook_type: HookType, hook: Hook): + """Register a hook for all actions and sub-actions.""" + self.hooks.register(hook_type, hook) + + def __str__(self): + return f"<{self.__class__.__name__} '{self.name}'>" + + def __repr__(self): + return str(self) + + +class Action(BaseAction): + """A simple action that runs a callable. It can be a function or a coroutine.""" + def __init__( + self, + name: str, + action, + rollback=None, + args: tuple[Any, ...] = (), + kwargs: dict[str, Any] | None = None, + hooks: HookManager | None = None, + inject_last_result: bool = False, + inject_last_result_as: str = "last_result", + ) -> None: + super().__init__(name, hooks, inject_last_result, inject_last_result_as) + self.action = ensure_async(action) + self.rollback = rollback + self.args = args + self.kwargs = kwargs or {} + self.is_retryable = True + + async def _run(self, *args, **kwargs) -> Any: + combined_args = args + self.args + combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs}) + + context = ExecutionContext( + name=self.name, + args=combined_args, + kwargs=combined_kwargs, + action=self, + ) + context.start_timer() + try: + await self.hooks.trigger(HookType.BEFORE, context) + result = await self.action(*combined_args, **combined_kwargs) + context.result = result + await self.hooks.trigger(HookType.ON_SUCCESS, context) + return context.result + except Exception as error: + context.exception = error + await self.hooks.trigger(HookType.ON_ERROR, context) + if context.result is not None: + logger.info("[%s] βœ… Recovered: %s", self.name, self.name) + return context.result + raise error + finally: + context.stop_timer() + await self.hooks.trigger(HookType.AFTER, context) + await self.hooks.trigger(HookType.ON_TEARDOWN, context) + er.record(context) + + async def preview(self, parent: Tree | None = None): + label = f"[{OneColors.GREEN_b}]βš™ Action[/] '{self.name}'" + if self.inject_last_result: + label += f" [dim](injects '{self.inject_last_result_as}')[/dim]" + if parent: + parent.add(label) + else: + console.print(Tree(label)) + + +class ActionListMixin: + """Mixin for managing a list of actions.""" + def __init__(self) -> None: + self.actions: list[BaseAction] = [] + + def set_actions(self, actions: list[BaseAction]) -> None: + """Replaces the current action list with a new one.""" + self.actions.clear() + for action in actions: + self.add_action(action) + + def add_action(self, action: BaseAction) -> None: + """Adds an action to the list.""" + self.actions.append(action) + + def remove_action(self, name: str) -> None: + """Removes an action by name.""" + self.actions = [action for action in self.actions if action.name != name] + + def has_action(self, name: str) -> bool: + """Checks if an action with the given name exists.""" + return any(action.name == name for action in self.actions) + + def get_action(self, name: str) -> BaseAction | None: + """Retrieves an action by name.""" + for action in self.actions: + if action.name == name: + return action + return None + + +class ChainedAction(BaseAction, ActionListMixin): + """A ChainedAction is a sequence of actions that are executed in order.""" + def __init__( + self, + name: str, + actions: list[BaseAction] | None = None, + hooks: HookManager | None = None, + inject_last_result: bool = False, + inject_last_result_as: str = "last_result", + ) -> None: + super().__init__(name, hooks, inject_last_result, inject_last_result_as) + ActionListMixin.__init__(self) + if actions: + self.set_actions(actions) + + async def _run(self, *args, **kwargs) -> list[Any]: + results_context = ResultsContext(name=self.name) + if self.results_context: + results_context.add_result(self.results_context.last_result()) + updated_kwargs = self._maybe_inject_last_result(kwargs) + context = ExecutionContext( + name=self.name, + args=args, + kwargs=updated_kwargs, + action=self, + extra={"results": [], "rollback_stack": []}, + ) + context.start_timer() + try: + await self.hooks.trigger(HookType.BEFORE, context) + + for index, action in enumerate(self.actions): + results_context.current_index = index + prepared = action.prepare_for_chain(results_context) + result = await prepared(*args, **updated_kwargs) + results_context.add_result(result) + context.extra["results"].append(result) + context.extra["rollback_stack"].append(prepared) + + context.result = context.extra["results"] + await self.hooks.trigger(HookType.ON_SUCCESS, context) + return context.result + + except Exception as error: + context.exception = error + results_context.errors.append((results_context.current_index, error)) + await self._rollback(context.extra["rollback_stack"], *args, **kwargs) + await self.hooks.trigger(HookType.ON_ERROR, context) + raise + finally: + context.stop_timer() + await self.hooks.trigger(HookType.AFTER, context) + await self.hooks.trigger(HookType.ON_TEARDOWN, context) + er.record(context) + + async def _rollback(self, rollback_stack, *args, **kwargs): + for action in reversed(rollback_stack): + rollback = getattr(action, "rollback", None) + if rollback: + try: + logger.warning("[%s] ↩️ Rolling back...", action.name) + await action.rollback(*args, **kwargs) + except Exception as error: + logger.error("[%s]⚠️ Rollback failed: %s", action.name, error) + + async def preview(self, parent: Tree | None = None): + label = f"[{OneColors.CYAN_b}]β›“ ChainedAction[/] '{self.name}'" + if self.inject_last_result: + label += f" [dim](injects '{self.inject_last_result_as}')[/dim]" + tree = parent.add(label) if parent else Tree(label) + for action in self.actions: + await action.preview(parent=tree) + if not parent: + console.print(tree) + + def register_hooks_recursively(self, hook_type: HookType, hook: Hook): + """Register a hook for all actions and sub-actions.""" + self.hooks.register(hook_type, hook) + for action in self.actions: + action.register_hooks_recursively(hook_type, hook) + + +class ActionGroup(BaseAction, ActionListMixin): + """An ActionGroup is a collection of actions that can be run in parallel.""" + def __init__( + self, + name: str, + actions: list[BaseAction] | None = None, + hooks: HookManager | None = None, + inject_last_result: bool = False, + inject_last_result_as: str = "last_result", + ): + super().__init__(name, hooks, inject_last_result, inject_last_result_as) + ActionListMixin.__init__(self) + if actions: + 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()) + updated_kwargs = self._maybe_inject_last_result(kwargs) + context = ExecutionContext( + name=self.name, + args=args, + kwargs=updated_kwargs, + action=self, + extra={"results": [], "errors": []}, + ) + async def run_one(action: BaseAction): + try: + prepared = action.prepare_for_group(results_context) + result = await prepared(*args, **updated_kwargs) + results_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)) + context.extra["errors"].append((action.name, error)) + + context.start_timer() + try: + await self.hooks.trigger(HookType.BEFORE, context) + await asyncio.gather(*[run_one(a) for a in self.actions]) + + if context.extra["errors"]: + context.exception = Exception( + f"{len(context.extra['errors'])} action(s) failed: " + + ", ".join(name for name, _ in context.extra["errors"]) + ) + await self.hooks.trigger(HookType.ON_ERROR, context) + raise context.exception + + context.result = context.extra["results"] + await self.hooks.trigger(HookType.ON_SUCCESS, context) + return context.result + + except Exception as error: + context.exception = error + raise + finally: + context.stop_timer() + await self.hooks.trigger(HookType.AFTER, context) + await self.hooks.trigger(HookType.ON_TEARDOWN, context) + er.record(context) + + async def preview(self, parent: Tree | None = None): + label = f"[{OneColors.MAGENTA_b}]⏩ ActionGroup (parallel)[/] '{self.name}'" + if self.inject_last_result: + label += f" [dim](receives '{self.inject_last_result_as}')[/dim]" + tree = parent.add(label) if parent else Tree(label) + actions = self.actions.copy() + random.shuffle(actions) + await asyncio.gather(*(action.preview(parent=tree) for action in actions)) + if not parent: + console.print(tree) + + def register_hooks_recursively(self, hook_type: HookType, hook: Hook): + """Register a hook for all actions and sub-actions.""" + super().register_hooks_recursively(hook_type, hook) + for action in self.actions: + action.register_hooks_recursively(hook_type, hook) + + +class ProcessAction(BaseAction): + """A ProcessAction runs a function in a separate process using ProcessPoolExecutor.""" + def __init__( + self, + name: str, + func: Callable[..., Any], + args: tuple = (), + kwargs: dict[str, Any] | None = None, + hooks: HookManager | None = None, + executor: ProcessPoolExecutor | None = None, + inject_last_result: bool = False, + inject_last_result_as: str = "last_result", + ): + super().__init__(name, hooks, inject_last_result, inject_last_result_as) + self.func = func + self.args = args + self.kwargs = kwargs or {} + self.executor = executor or ProcessPoolExecutor() + self.is_retryable = True + + async def _run(self, *args, **kwargs): + if self.inject_last_result: + last_result = self.results_context.last_result() + if not self._validate_pickleable(last_result): + raise ValueError( + f"Cannot inject last result into {self.name}: " + f"last result is not pickleable." + ) + combined_args = args + self.args + combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs}) + context = ExecutionContext( + name=self.name, + args=combined_args, + kwargs=combined_kwargs, + action=self, + ) + loop = asyncio.get_running_loop() + + context.start_timer() + try: + await self.hooks.trigger(HookType.BEFORE, context) + result = await loop.run_in_executor( + self.executor, partial(self.func, *combined_args, **combined_kwargs) + ) + context.result = result + await self.hooks.trigger(HookType.ON_SUCCESS, context) + return result + except Exception as error: + context.exception = error + await self.hooks.trigger(HookType.ON_ERROR, context) + if context.result is not None: + return context.result + raise + finally: + context.stop_timer() + await self.hooks.trigger(HookType.AFTER, context) + await self.hooks.trigger(HookType.ON_TEARDOWN, context) + er.record(context) + + async def preview(self, parent: Tree | None = None): + label = f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessAction (new process)[/] '{self.name}'" + if self.inject_last_result: + label += f" [dim](injects '{self.inject_last_result_as}')[/dim]" + if parent: + parent.add(label) + else: + console.print(Tree(label)) + + def _validate_pickleable(self, obj: Any) -> bool: + try: + import pickle + pickle.dumps(obj) + print("YES") + return True + except (pickle.PicklingError, TypeError): + print("NO") + return False diff --git a/falyx/bottom_bar.py b/falyx/bottom_bar.py new file mode 100644 index 0000000..aaeadeb --- /dev/null +++ b/falyx/bottom_bar.py @@ -0,0 +1,150 @@ +"""bottom_bar.py""" +from typing import Any, Callable, Optional + +from prompt_toolkit.formatted_text import HTML, merge_formatted_text +from prompt_toolkit.key_binding import KeyBindings +from rich.console import Console + +from falyx.themes.colors import OneColors +from falyx.utils import CaseInsensitiveDict + + +class BottomBar: + """Bottom Bar class for displaying a bottom bar in the terminal.""" + def __init__(self, columns: int = 3, key_bindings: KeyBindings | None = None): + self.columns = columns + self.console = Console() + self._items: list[Callable[[], HTML]] = [] + self._named_items: dict[str, Callable[[], HTML]] = {} + self._states: dict[str, Any] = CaseInsensitiveDict() + self.toggles: list[str] = [] + self.key_bindings = key_bindings or KeyBindings() + + def get_space(self) -> int: + return self.console.width // self.columns + + def add_static( + self, name: str, text: str, fg: str = OneColors.BLACK, bg: str = OneColors.WHITE + ) -> None: + def render(): + return HTML( + f"" + ) + + self._add_named(name, render) + + def add_counter( + self, + name: str, + label: str, + current: int, + fg: str = OneColors.BLACK, + bg: str = OneColors.WHITE, + ) -> None: + self._states[name] = (label, current) + + def render(): + label_, current_ = self._states[name] + text = f"{label_}: {current_}" + return HTML( + f"" + ) + + self._add_named(name, render) + + def add_total_counter( + self, + name: str, + label: str, + current: int, + total: int, + fg: str = OneColors.BLACK, + bg: str = OneColors.WHITE, + ) -> None: + self._states[name] = (label, current, total) + + if current > total: + raise ValueError( + f"Current value {current} is greater than total value {total}" + ) + + def render(): + label_, current_, text_ = self._states[name] + text = f"{label_}: {current_}/{text_}" + return HTML( + f"" + ) + + self._add_named(name, render) + + def add_toggle( + self, + key: str, + label: str, + state: bool, + fg: str = OneColors.BLACK, + bg_on: str = OneColors.GREEN, + bg_off: str = OneColors.DARK_RED, + ) -> None: + key = key.upper() + if key in self.toggles: + raise ValueError(f"Key {key} is already used as a toggle") + self._states[key] = (label, state) + self.toggles.append(key) + + def render(): + label_, state_ = self._states[key] + color = bg_on if state_ else bg_off + status = "ON" if state_ else "OFF" + text = f"({key.upper()}) {label_}: {status}" + return HTML( + f"" + ) + + self._add_named(key, render) + + for k in (key.upper(), key.lower()): + + @self.key_bindings.add(k) + def _(event, key=k): + self.toggle_state(key) + + def toggle_state(self, name: str) -> bool: + label, state = self._states.get(name, (None, False)) + new_state = not state + self.update_toggle(name, new_state) + return new_state + + def update_toggle(self, name: str, state: bool) -> None: + if name in self._states: + label, _ = self._states[name] + self._states[name] = (label, state) + + def increment_counter(self, name: str) -> None: + if name in self._states: + label, current = self._states[name] + self._states[name] = (label, current + 1) + + def increment_total_counter(self, name: str) -> None: + if name in self._states: + label, current, total = self._states[name] + if current < total: + self._states[name] = (label, current + 1, total) + + def update_counter( + self, name: str, current: Optional[int] = None, total: Optional[int] = None + ) -> None: + if name in self._states: + label, c, t = self._states[name] + self._states[name] = ( + label, + current if current is not None else c, + total if total is not None else t, + ) + + def _add_named(self, name: str, render_fn: Callable[[], HTML]) -> None: + self._named_items[name] = render_fn + self._items = list(self._named_items.values()) + + def render(self): + return merge_formatted_text([fn() for fn in self._items]) diff --git a/falyx/command.py b/falyx/command.py new file mode 100644 index 0000000..7f93710 --- /dev/null +++ b/falyx/command.py @@ -0,0 +1,173 @@ +"""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 +""" +from __future__ import annotations + +from typing import Any, Callable + +from prompt_toolkit.formatted_text import FormattedText +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator +from rich.console import Console +from rich.tree import Tree + +from falyx.action import BaseAction +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.retry import RetryHandler, RetryPolicy +from falyx.themes.colors import OneColors +from falyx.utils import _noop, ensure_async, logger + +console = Console() + + +class Command(BaseModel): + """Class representing an command in the menu.""" + 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) + help_text: str = "" + color: str = OneColors.WHITE + confirm: bool = False + confirm_message: str = "Are you sure?" + preview_before_confirm: bool = True + spinner: bool = False + spinner_message: str = "Processing..." + spinner_type: str = "dots" + spinner_style: str = OneColors.CYAN + spinner_kwargs: dict[str, Any] = Field(default_factory=dict) + hooks: "HookManager" = Field(default_factory=HookManager) + retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) + tags: list[str] = Field(default_factory=list) + logging_hooks: bool = False + + _context: ExecutionContext | None = PrivateAttr(default=None) + + model_config = ConfigDict(arbitrary_types_allowed=True) + + def model_post_init(self, __context: Any) -> None: + """Post-initialization to set up the action and hooks.""" + self._auto_register_retry_hook() + if self.logging_hooks and isinstance(self.action, BaseAction): + register_debug_hooks(self.action.hooks) + + def _auto_register_retry_hook(self): + if ( + self.retry_policy.is_active() and + isinstance(self.action, BaseAction) and + self.action.is_retryable + ): + logger.debug(f"Auto-registering retry handler for action: {self.key}") + retry_handler = RetryHandler(self.retry_policy) + self.action.hooks.register(HookType.ON_ERROR, retry_handler.retry_on_error) + elif self.retry_policy.is_active(): + logger.debug(f"Retry policy is active but action is not retryable: {self.key}") + + def update_retry_policy(self, policy: RetryPolicy): + self.retry_policy = policy + self._auto_register_retry_hook() + + @field_validator("action", mode="before") + @classmethod + def wrap_callable_as_async(cls, action: Any) -> Any: + if isinstance(action, BaseAction): + return action + elif callable(action): + return ensure_async(action) + raise TypeError("Action must be a callable or an instance of BaseAction") + + def __str__(self): + return f"Command(key='{self.key}', description='{self.description}')" + + async def __call__(self, *args, **kwargs): + """Run the action with full hook lifecycle, timing, and error handling.""" + combined_args = args + self.args + combined_kwargs = {**self.kwargs, **kwargs} + context = ExecutionContext( + name=self.description, + args=combined_args, + kwargs=combined_kwargs, + action=self, + ) + self._context = context + context.start_timer() + + try: + await self.hooks.trigger(HookType.BEFORE, context) + result = await self.action(*combined_args, **combined_kwargs) + context.result = result + await self.hooks.trigger(HookType.ON_SUCCESS, context) + return context.result + except Exception as error: + context.exception = error + await self.hooks.trigger(HookType.ON_ERROR, context) + if context.result is not None: + logger.info(f"βœ… Recovered: {self.key}") + return context.result + raise error + finally: + context.stop_timer() + await self.hooks.trigger(HookType.AFTER, context) + await self.hooks.trigger(HookType.ON_TEARDOWN, context) + er.record(context) + + @property + def result(self) -> Any: + """Get the result of the action.""" + return self._context.result if self._context else None + + @property + def confirmation_prompt(self) -> FormattedText: + """Generate a styled prompt_toolkit FormattedText confirmation message.""" + if self.confirm_message and self.confirm_message != "Are you sure?": + return FormattedText([ + ("class:confirm", self.confirm_message) + ]) + + action_name = getattr(self.action, "__name__", None) + if isinstance(self.action, BaseAction): + action_name = self.action.name + + prompt = [(OneColors.WHITE, "Confirm execution of ")] + + prompt.append((OneColors.BLUE_b, f"{self.key}")) + prompt.append((OneColors.BLUE_b, f" β€” {self.description} ")) + + if action_name: + prompt.append(("class:confirm", f"(calls `{action_name}`) ")) + + if self.args or self.kwargs: + prompt.append((OneColors.DARK_YELLOW, f"with args={self.args}, kwargs={self.kwargs} ")) + + return FormattedText(prompt) + + def log_summary(self): + if self._context: + self._context.log_summary() + + async def preview(self): + label = f"[{OneColors.GREEN_b}]Command:[/] '{self.key}' β€” {self.description}" + + if hasattr(self.action, "preview") and callable(self.action.preview): + tree = Tree(label) + await self.action.preview(parent=tree) + console.print(tree) + elif callable(self.action): + console.print(f"{label}") + console.print( + f"[{OneColors.LIGHT_RED_b}]β†’ Would call:[/] {self.action.__name__} " + f"[dim](args={self.args}, kwargs={self.kwargs})[/dim]" + ) + else: + console.print(f"{label}") + console.print(f"[{OneColors.DARK_RED}]⚠️ Action is not callable or lacks a preview method.[/]") diff --git a/falyx/config.py b/falyx/config.py new file mode 100644 index 0000000..9a41454 --- /dev/null +++ b/falyx/config.py @@ -0,0 +1,103 @@ +"""config.py +Configuration loader for Falyx CLI commands.""" + +import importlib +from pathlib import Path +from typing import Any + +import toml +import yaml + +from falyx.action import Action, BaseAction +from falyx.command import Command +from falyx.retry import RetryPolicy + + +def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command: + if isinstance(obj, (BaseAction, Command)): + return obj + elif callable(obj): + return Action(name=name or getattr(obj, "__name__", "unnamed"), action=obj) + else: + raise TypeError( + f"Cannot wrap object of type '{type(obj).__name__}' as a BaseAction or Command. " + "It must be a callable or an instance of BaseAction." + ) + + +def import_action(dotted_path: str) -> Any: + """Dynamically imports a callable from a dotted path like 'my.module.func'.""" + module_path, _, attr = dotted_path.rpartition(".") + if not module_path: + raise ValueError(f"Invalid action path: {dotted_path}") + module = importlib.import_module(module_path) + return getattr(module, attr) + + +def loader(file_path: str) -> list[dict[str, Any]]: + """ + Load command definitions from a YAML or TOML file. + + Each command should be defined as a dictionary with at least: + - key: a unique single-character key + - description: short description + - action: dotted import path to the action function/class + + Args: + file_path (str): Path to the config file (YAML or TOML). + + Returns: + list[dict[str, Any]]: A list of command configuration dictionaries. + + Raises: + ValueError: If the file format is unsupported or file cannot be parsed. + """ + path = Path(file_path) + if not path.is_file(): + raise FileNotFoundError(f"No such config file: {file_path}") + + suffix = path.suffix + with path.open("r", encoding="UTF-8") as config_file: + if suffix in (".yaml", ".yml"): + raw_config = yaml.safe_load(config_file) + elif suffix == ".toml": + raw_config = toml.load(config_file) + else: + raise ValueError(f"Unsupported config format: {suffix}") + + if not isinstance(raw_config, list): + raise ValueError("Configuration file must contain a list of command definitions.") + + + required = ["key", "description", "action"] + commands = [] + for entry in raw_config: + for field in required: + if field not in entry: + raise ValueError(f"Missing '{field}' in command entry: {entry}") + + 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", {}), + "help_text": entry.get("help_text", ""), + "color": entry.get("color", "white"), + "confirm": entry.get("confirm", False), + "confirm_message": entry.get("confirm_message", "Are you sure?"), + "preview_before_confirm": entry.get("preview_before_confirm", True), + "spinner": entry.get("spinner", False), + "spinner_message": entry.get("spinner_message", "Processing..."), + "spinner_type": entry.get("spinner_type", "dots"), + "spinner_style": entry.get("spinner_style", "cyan"), + "spinner_kwargs": entry.get("spinner_kwargs", {}), + "tags": entry.get("tags", []), + "retry_policy": RetryPolicy(**entry.get("retry_policy", {})), + } + commands.append(command_dict) + + return commands + diff --git a/falyx/context.py b/falyx/context.py new file mode 100644 index 0000000..453c66b --- /dev/null +++ b/falyx/context.py @@ -0,0 +1,127 @@ +"""context.py""" +import time +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class ExecutionContext(BaseModel): + name: str + args: tuple = () + kwargs: dict = {} + action: Any + result: Any | None = None + exception: Exception | None = None + start_time: float | None = None + end_time: float | None = None + extra: dict[str, Any] = Field(default_factory=dict) + + model_config = ConfigDict(arbitrary_types_allowed=True) + + def start_timer(self): + self.start_time = time.perf_counter() + + def stop_timer(self): + self.end_time = time.perf_counter() + + @property + def duration(self) -> Optional[float]: + if self.start_time is not None and self.end_time is not None: + return self.end_time - self.start_time + return None + + @property + def success(self) -> bool: + return self.exception is None + + @property + def status(self) -> str: + return "OK" if self.success else "ERROR" + + def as_dict(self) -> dict: + return { + "name": self.name, + "result": self.result, + "exception": repr(self.exception) if self.exception else None, + "duration": self.duration, + "extra": self.extra, + } + + def log_summary(self, logger=None): + summary = self.as_dict() + msg = f"[SUMMARY] {summary['name']} | " + + if self.start_time: + start_str = datetime.fromtimestamp(self.start_time).strftime("%H:%M:%S") + msg += f"Start: {start_str} | " + + if self.end_time: + end_str = datetime.fromtimestamp(self.end_time).strftime("%H:%M:%S") + msg += f"End: {end_str} | " + + msg += f"Duration: {summary['duration']:.3f}s | " + + if summary["exception"]: + msg += f"❌ Exception: {summary['exception']}" + else: + msg += f"βœ… Result: {summary['result']}" + (logger or print)(msg) + + def to_log_line(self) -> str: + """Structured flat-line format for logging and metrics.""" + duration_str = f"{self.duration:.3f}s" if self.duration is not None else "n/a" + exception_str = f"{type(self.exception).__name__}: {self.exception}" if self.exception else "None" + return ( + f"[{self.name}] status={self.status} duration={duration_str} " + f"result={repr(self.result)} exception={exception_str}" + ) + + def __str__(self) -> str: + duration_str = f"{self.duration:.3f}s" if self.duration is not None else "n/a" + result_str = f"Result: {repr(self.result)}" if self.success else f"Exception: {self.exception}" + return ( + f"" + ) + + def __repr__(self) -> str: + return ( + f"ExecutionContext(" + f"name={self.name!r}, " + f"duration={f'{self.duration:.3f}' if self.duration is not None else 'n/a'}, " + f"result={self.result!r}, " + f"exception={self.exception!r})" + ) + + +class ResultsContext(BaseModel): + name: str + results: list[Any] = Field(default_factory=list) + errors: list[tuple[int, Exception]] = Field(default_factory=list) + current_index: int = -1 + is_parallel: bool = False + shared_result: Any | None = None + + model_config = ConfigDict(arbitrary_types_allowed=True) + + def add_result(self, result: Any) -> None: + self.results.append(result) + + def set_shared_result(self, result: Any) -> None: + self.shared_result = result + if self.is_parallel: + self.results.append(result) + + def last_result(self) -> Any: + if self.is_parallel: + return self.shared_result + return self.results[-1] if self.results else None + + def __str__(self) -> str: + parallel_label = "Parallel" if self.is_parallel else "Sequential" + return ( + f"<{parallel_label}ResultsContext '{self.name}' | " + f"Results: {self.results} | " + f"Errors: {self.errors}>" + ) diff --git a/falyx/debug.py b/falyx/debug.py new file mode 100644 index 0000000..53ab195 --- /dev/null +++ b/falyx/debug.py @@ -0,0 +1,43 @@ +from falyx.context import ExecutionContext +from falyx.hook_manager import HookManager, HookType +from falyx.utils import logger + + +def log_before(context: ExecutionContext): + """Log the start of an action.""" + args = ", ".join(map(repr, context.args)) + kwargs = ", ".join(f"{k}={v!r}" for k, v in context.kwargs.items()) + signature = ", ".join(filter(None, [args, kwargs])) + logger.info("[%s] πŸš€ Starting β†’ %s(%s)", context.name, context.action, signature) + + +def log_success(context: ExecutionContext): + """Log the successful completion of an action.""" + result_str = repr(context.result) + if len(result_str) > 100: + result_str = result_str[:100] + "..." + logger.debug("[%s] βœ… Success β†’ Result: %s", context.name, result_str) + + +def log_after(context: ExecutionContext): + """Log the completion of an action, regardless of success or failure.""" + logger.debug("[%s] ⏱️ Finished in %.3fs", context.name, context.duration) + + +def log_error(context: ExecutionContext): + """Log an error that occurred during the action.""" + logger.error( + "[%s] ❌ Error (%s): %s", + context.name, + type(context.exception).__name__, + context.exception, + exc_info=True, + ) + + +def register_debug_hooks(hooks: HookManager): + hooks.register(HookType.BEFORE, log_before) + hooks.register(HookType.AFTER, log_after) + hooks.register(HookType.ON_SUCCESS, log_success) + hooks.register(HookType.ON_ERROR, log_error) + diff --git a/falyx/exceptions.py b/falyx/exceptions.py new file mode 100644 index 0000000..311d3e9 --- /dev/null +++ b/falyx/exceptions.py @@ -0,0 +1,22 @@ +class FalyxError(Exception): + """Custom exception for the Menu class.""" + + +class CommandAlreadyExistsError(FalyxError): + """Exception raised when an command with the same key already exists in the menu.""" + + +class InvalidHookError(FalyxError): + """Exception raised when a hook is not callable.""" + + +class InvalidActionError(FalyxError): + """Exception raised when an action is not callable.""" + + +class NotAFalyxError(FalyxError): + """Exception raised when the provided submenu is not an instance of Menu.""" + + +class CircuitBreakerOpen(FalyxError): + """Exception raised when the circuit breaker is open.""" diff --git a/falyx/execution_registry.py b/falyx/execution_registry.py new file mode 100644 index 0000000..7a6c988 --- /dev/null +++ b/falyx/execution_registry.py @@ -0,0 +1,76 @@ +"""execution_registry.py""" +from collections import defaultdict +from datetime import datetime +from typing import Dict, List + +from rich import box +from rich.console import Console +from rich.table import Table + +from falyx.context import ExecutionContext +from falyx.utils import logger + + +class ExecutionRegistry: + _store_by_name: Dict[str, List[ExecutionContext]] = defaultdict(list) + _store_all: List[ExecutionContext] = [] + _console = Console(color_system="truecolor") + + @classmethod + def record(cls, context: ExecutionContext): + """Record an execution context.""" + logger.debug(context.to_log_line()) + cls._store_by_name[context.name].append(context) + cls._store_all.append(context) + + @classmethod + def get_all(cls) -> List[ExecutionContext]: + return cls._store_all + + @classmethod + def get_by_name(cls, name: str) -> List[ExecutionContext]: + return cls._store_by_name.get(name, []) + + @classmethod + def get_latest(cls) -> ExecutionContext: + return cls._store_all[-1] + + @classmethod + def clear(cls): + cls._store_by_name.clear() + cls._store_all.clear() + + @classmethod + def summary(cls): + table = Table(title="[πŸ“Š] Execution History", expand=True, box=box.SIMPLE) + + table.add_column("Name", style="bold cyan") + table.add_column("Start", justify="right", style="dim") + table.add_column("End", justify="right", style="dim") + table.add_column("Duration", justify="right") + table.add_column("Status", style="bold") + table.add_column("Result / Exception", overflow="fold") + + for ctx in cls.get_all(): + start = datetime.fromtimestamp(ctx.start_time).strftime("%H:%M:%S") if ctx.start_time else "n/a" + end = datetime.fromtimestamp(ctx.end_time).strftime("%H:%M:%S") if ctx.end_time else "n/a" + duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a" + + if ctx.exception: + status = "[bold red]❌ Error" + result = repr(ctx.exception) + else: + status = "[green]βœ… Success" + result = repr(ctx.result) + + table.add_row(ctx.name, start, end, duration, status, result) + + cls._console.print(table) + + @classmethod + def get_history_action(cls) -> "Action": + """Return an Action that prints the execution summary.""" + from falyx.action import Action + async def show_history(): + cls.summary() + return Action(name="View Execution History", action=show_history) diff --git a/falyx/falyx.py b/falyx/falyx.py new file mode 100644 index 0000000..84a52e8 --- /dev/null +++ b/falyx/falyx.py @@ -0,0 +1,831 @@ +"""falyx.py + +This class creates a Falyx object that creates a selectable menu +with customizable commands and functionality. + +It allows for adding commands, and their accompanying actions, +and provides a method to display the menu and handle user input. + +This class uses the `rich` library to display the menu in a +formatted and visually appealing way. + +This class also uses the `prompt_toolkit` library to handle +user input and create an interactive experience. +""" +import asyncio +import logging +import sys +from argparse import ArgumentParser, Namespace +from difflib import get_close_matches +from functools import cached_property +from typing import Any, Callable + +from prompt_toolkit import PromptSession +from prompt_toolkit.completion import WordCompleter +from prompt_toolkit.formatted_text import AnyFormattedText +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.validation import Validator +from rich import box +from rich.console import Console +from rich.markdown import Markdown +from rich.table import Table + +from falyx.action import BaseAction +from falyx.bottom_bar import BottomBar +from falyx.command import Command +from falyx.context import ExecutionContext +from falyx.debug import register_debug_hooks, log_before, log_success, log_error, log_after +from falyx.exceptions import (CommandAlreadyExistsError, FalyxError, + InvalidActionError, NotAFalyxError) +from falyx.execution_registry import ExecutionRegistry as er +from falyx.hook_manager import HookManager, HookType, Hook +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.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. + """ + + def __init__( + self, + title: str | Markdown = "Menu", + prompt: str | AnyFormattedText = "> ", + columns: int = 3, + bottom_bar: BottomBar | str | Callable[[], None] | None = None, + welcome_message: str | Markdown = "", + exit_message: str | Markdown = "", + key_bindings: KeyBindings | None = None, + include_history_command: bool = True, + include_help_command: bool = False, + confirm_on_error: bool = True, + never_confirm: bool = False, + always_confirm: bool = False, + cli_args: Namespace | None = None, + custom_table: Callable[["Falyx"], Table] | Table | None = None, + ) -> None: + """Initializes the Falyx object.""" + self.title: str | Markdown = title + self.prompt: str | AnyFormattedText = prompt + self.columns: int = columns + self.commands: dict[str, Command] = CaseInsensitiveDict() + self.exit_command: Command = self._get_exit_command() + self.history_command: Command | None = self._get_history_command() if include_history_command else None + self.help_command: Command | None = self._get_help_command() if include_help_command else None + self.console: Console = Console(color_system="truecolor", theme=get_nord_theme()) + self.welcome_message: str | Markdown = welcome_message + self.exit_message: str | Markdown = exit_message + self.hooks: HookManager = HookManager() + self.last_run_command: Command | None = None + self.key_bindings: KeyBindings = key_bindings or KeyBindings() + self.bottom_bar: BottomBar | str | Callable[[], None] = bottom_bar or BottomBar(columns=columns, key_bindings=self.key_bindings) + self.confirm_on_error: bool = confirm_on_error + self._never_confirm: bool = never_confirm + self._always_confirm: bool = always_confirm + self.cli_args: Namespace | None = cli_args + self.custom_table: Callable[["Falyx"], Table] | Table | None = custom_table + + @property + def _name_map(self) -> dict[str, Command]: + """Builds a mapping of all valid input names (keys, aliases, normalized names) to Command objects. + If a collision occurs, logs a warning and keeps the first registered command. + """ + mapping: dict[str, Command] = {} + + def register(name: str, cmd: Command): + norm = name.upper().strip() + if norm in mapping: + existing = mapping[norm] + if existing is not cmd: + logger.warning( + f"[alias conflict] '{name}' already assigned to '{existing.description}'." + f" Skipping for '{cmd.description}'." + ) + else: + mapping[norm] = cmd + + for special in [self.exit_command, self.history_command, self.help_command]: + if special: + register(special.key, special) + for alias in special.aliases: + register(alias, special) + register(special.description, special) + + for cmd in self.commands.values(): + register(cmd.key, cmd) + for alias in cmd.aliases: + register(alias, cmd) + register(cmd.description, cmd) + return mapping + + def get_title(self) -> str: + """Returns the string title of the menu.""" + if isinstance(self.title, str): + return self.title + elif isinstance(self.title, Markdown): + return self.title.markup + return self.title + + def _get_exit_command(self) -> Command: + """Returns the back command for the menu.""" + return Command( + key="Q", + description="Exit", + aliases=["EXIT", "QUIT"], + color=OneColors.DARK_RED, + ) + + def _get_history_command(self) -> Command: + """Returns the history command for the menu.""" + return Command( + key="Y", + description="History", + aliases=["HISTORY"], + action=er.get_history_action(), + color=OneColors.DARK_YELLOW, + ) + + async def _show_help(self): + table = Table(title="[bold cyan]Help Menu[/]", box=box.SIMPLE) + table.add_column("Key", style="bold", no_wrap=True) + table.add_column("Aliases", style="dim", no_wrap=True) + table.add_column("Description", style="dim", overflow="fold") + table.add_column("Tags", style="dim", no_wrap=True) + + for command in self.commands.values(): + help_text = command.help_text or command.description + table.add_row( + f"[{command.color}]{command.key}[/]", + ", ".join(command.aliases) if command.aliases else "None", + help_text, + ", ".join(command.tags) if command.tags else "None" + ) + + table.add_row( + f"[{self.exit_command.color}]{self.exit_command.key}[/]", + ", ".join(self.exit_command.aliases), + "Exit this menu or program" + ) + + if self.history_command: + table.add_row( + f"[{self.history_command.color}]{self.history_command.key}[/]", + ", ".join(self.history_command.aliases), + "History of executed actions" + ) + + if self.help_command: + table.add_row( + f"[{self.help_command.color}]{self.help_command.key}[/]", + ", ".join(self.help_command.aliases), + "Show this help menu" + ) + + self.console.print(table) + + def _get_help_command(self) -> Command: + """Returns the help command for the menu.""" + return Command( + key="H", + aliases=["HELP"], + description="Help", + action=self._show_help, + color=OneColors.LIGHT_YELLOW, + ) + def _get_completer(self) -> WordCompleter: + """Completer to provide auto-completion for the menu commands.""" + keys = [self.exit_command.key] + keys.extend(self.exit_command.aliases) + if self.history_command: + keys.append(self.history_command.key) + keys.extend(self.history_command.aliases) + if self.help_command: + keys.append(self.help_command.key) + keys.extend(self.help_command.aliases) + for cmd in self.commands.values(): + keys.append(cmd.key) + keys.extend(cmd.aliases) + return WordCompleter(keys, ignore_case=True) + + def _get_validator(self) -> Validator: + """Validator to check if the input is a valid command or toggle key.""" + keys = {self.exit_command.key.upper()} + keys.update({alias.upper() for alias in self.exit_command.aliases}) + if self.history_command: + keys.add(self.history_command.key.upper()) + keys.update({alias.upper() for alias in self.history_command.aliases}) + if self.help_command: + keys.add(self.help_command.key.upper()) + keys.update({alias.upper() for alias in self.help_command.aliases}) + + for cmd in self.commands.values(): + keys.add(cmd.key.upper()) + keys.update({alias.upper() for alias in cmd.aliases}) + + if isinstance(self.bottom_bar, BottomBar): + toggle_keys = {key.upper() for key in self.bottom_bar.toggles} + else: + toggle_keys = set() + + commands_str = ", ".join(sorted(keys)) + toggles_str = ", ".join(sorted(toggle_keys)) + + message_lines = ["Invalid input. Available keys:"] + if keys: + message_lines.append(f" Commands: {commands_str}") + if toggle_keys: + message_lines.append(f" Toggles: {toggles_str}") + error_message = " ".join(message_lines) + + def validator(text): + return True if self.get_command(text, from_validate=True) else False + + return Validator.from_callable( + validator, + error_message=error_message, + move_cursor_to_end=True, + ) + + def _invalidate_session_cache(self): + """Forces the session to be recreated on the next access.""" + if hasattr(self, "session"): + del self.session + + def add_toggle(self, key: str, label: str, state: bool) -> None: + """Adds a toggle to the bottom bar.""" + assert isinstance(self.bottom_bar, BottomBar), "Bottom bar must be an instance of BottomBar." + self.bottom_bar.add_toggle(key, label, state) + self._invalidate_session_cache() + + def add_counter(self, name: str, label: str, current: int) -> None: + """Adds a counter to the bottom bar.""" + assert isinstance(self.bottom_bar, BottomBar), "Bottom bar must be an instance of BottomBar." + self.bottom_bar.add_counter(name, label, current) + self._invalidate_session_cache() + + def add_total_counter(self, name: str, label: str, current: int, total: int) -> None: + """Adds a counter to the bottom bar.""" + assert isinstance(self.bottom_bar, BottomBar), "Bottom bar must be an instance of BottomBar." + self.bottom_bar.add_total_counter(name, label, current, total) + self._invalidate_session_cache() + + def add_static(self, name: str, text: str) -> None: + """Adds a static element to the bottom bar.""" + assert isinstance(self.bottom_bar, BottomBar), "Bottom bar must be an instance of BottomBar." + self.bottom_bar.add_static(name, text) + self._invalidate_session_cache + + def get_toggle_state(self, key: str) -> bool | None: + assert isinstance(self.bottom_bar, BottomBar), "Bottom bar must be an instance of BottomBar." + if key.upper() in self.bottom_bar._states: + """Returns the state of a toggle.""" + return self.bottom_bar._states[key.upper()][1] + return None + + def add_help_command(self): + """Adds a help command to the menu if it doesn't already exist.""" + if not self.help_command: + self.help_command = self._get_help_command() + self._invalidate_session_cache() + + def add_history_command(self): + """Adds a history command to the menu if it doesn't already exist.""" + if not self.history_command: + self.history_command = self._get_history_command() + self._invalidate_session_cache() + + def _get_bottom_bar(self) -> Callable[[], Any] | str | None: + """Returns the bottom bar for the menu.""" + if isinstance(self.bottom_bar, BottomBar) and self.bottom_bar._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 + return None + + @cached_property + def session(self) -> PromptSession: + """Returns the prompt session for the menu.""" + return PromptSession( + message=self.prompt, + multiline=False, + completer=self._get_completer(), + reserve_space_for_menu=1, + validator=self._get_validator(), + bottom_toolbar=self._get_bottom_bar(), + key_bindings=self.key_bindings, + ) + + def register_all_hooks(self, hook_type: HookType, hooks: Hook | list[Hook]) -> None: + """Registers hooks for all commands in the menu and actions recursively.""" + hook_list = hooks if isinstance(hooks, list) else [hooks] + for hook in hook_list: + if not callable(hook): + raise InvalidActionError("Hook must be a callable.") + self.hooks.register(hook_type, hook) + for command in self.commands.values(): + command.hooks.register(hook_type, hook) + if isinstance(command.action, Falyx): + command.action.register_all_hooks(hook_type, hook) + if isinstance(command.action, BaseAction): + command.action.register_hooks_recursively(hook_type, hook) + + def register_all_with_debug_hooks(self) -> None: + """Registers debug hooks for all commands in the menu and actions recursively.""" + self.register_all_hooks(HookType.BEFORE, log_before) + self.register_all_hooks(HookType.ON_SUCCESS, log_success) + self.register_all_hooks(HookType.ON_ERROR, log_error) + self.register_all_hooks(HookType.AFTER, log_after) + + def debug_hooks(self) -> None: + """Logs the names of all hooks registered for the menu and its commands.""" + def hook_names(hook_list): + return [hook.__name__ for hook in hook_list] + + logger.debug(f"Menu-level before hooks: {hook_names(self.hooks._hooks[HookType.BEFORE])}") + logger.debug(f"Menu-level success hooks: {hook_names(self.hooks._hooks[HookType.ON_SUCCESS])}") + logger.debug(f"Menu-level error hooks: {hook_names(self.hooks._hooks[HookType.ON_ERROR])}") + logger.debug(f"Menu-level after hooks: {hook_names(self.hooks._hooks[HookType.AFTER])}") + logger.debug(f"Menu-level on_teardown hooks: {hook_names(self.hooks._hooks[HookType.ON_TEARDOWN])}") + + for key, command in self.commands.items(): + logger.debug(f"[Command '{key}'] before: {hook_names(command.hooks._hooks[HookType.BEFORE])}") + logger.debug(f"[Command '{key}'] success: {hook_names(command.hooks._hooks[HookType.ON_SUCCESS])}") + logger.debug(f"[Command '{key}'] error: {hook_names(command.hooks._hooks[HookType.ON_ERROR])}") + logger.debug(f"[Command '{key}'] after: {hook_names(command.hooks._hooks[HookType.AFTER])}") + logger.debug(f"[Command '{key}'] on_teardown: {hook_names(command.hooks._hooks[HookType.ON_TEARDOWN])}") + + def _validate_command_key(self, key: str) -> None: + """Validates the command key to ensure it is unique.""" + key = key.upper() + toggles = self.bottom_bar.toggles if isinstance(self.bottom_bar, BottomBar) else [] + collisions = [] + + if key in self.commands: + collisions.append("command") + if key == self.exit_command.key.upper(): + collisions.append("back command") + if self.history_command and key == self.history_command.key.upper(): + collisions.append("history command") + if self.help_command and key == self.help_command.key.upper(): + collisions.append("help command") + if key in toggles: + collisions.append("toggle") + + if collisions: + raise CommandAlreadyExistsError(f"Command key '{key}' conflicts with existing {', '.join(collisions)}.") + + def update_exit_command( + self, + key: str = "0", + description: str = "Exit", + action: Callable[[], Any] = lambda: None, + color: str = OneColors.DARK_RED, + confirm: bool = False, + confirm_message: str = "Are you sure?", + ) -> None: + """Updates the back command of the menu.""" + if not callable(action): + raise InvalidActionError("Action must be a callable.") + self._validate_command_key(key) + self.exit_command = Command( + key=key, + description=description, + action=action, + color=color, + confirm=confirm, + confirm_message=confirm_message, + ) + self._invalidate_session_cache() + + def add_submenu(self, key: str, description: str, submenu: "Falyx", color: str = OneColors.CYAN) -> None: + """Adds a submenu to the menu.""" + if not isinstance(submenu, Falyx): + raise NotAFalyxError("submenu must be an instance of Falyx.") + self._validate_command_key(key) + self.add_command(key, description, submenu.menu, color=color) + self._invalidate_session_cache() + + def add_commands(self, commands: list[dict]) -> None: + """Adds multiple commands to the menu.""" + for command in commands: + self.add_command(**command) + + def add_command( + self, + key: str, + description: str, + action: BaseAction | Callable[[], Any], + aliases: list[str] | None = None, + args: tuple = (), + kwargs: dict[str, Any] = {}, + help_text: str = "", + color: str = OneColors.WHITE, + confirm: bool = False, + confirm_message: str = "Are you sure?", + preview_before_confirm: bool = True, + spinner: bool = False, + spinner_message: str = "Processing...", + spinner_type: str = "dots", + spinner_style: str = OneColors.CYAN, + spinner_kwargs: dict[str, Any] | None = None, + 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, + teardown_hooks: list[Callable] | None = None, + tags: list[str] | None = None, + retry_policy: RetryPolicy | 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, + color=color, + confirm=confirm, + confirm_message=confirm_message, + preview_before_confirm=preview_before_confirm, + spinner=spinner, + spinner_message=spinner_message, + spinner_type=spinner_type, + spinner_style=spinner_style, + spinner_kwargs=spinner_kwargs or {}, + tags=tags if tags else [], + retry_policy=retry_policy if retry_policy else RetryPolicy(), + ) + + if hooks: + if not isinstance(hooks, HookManager): + raise NotAFalyxError("hooks must be an instance of HookManager.") + command.hooks = hooks + + for hook in before_hooks or []: + command.hooks.register(HookType.BEFORE, hook) + for hook in success_hooks or []: + command.hooks.register(HookType.ON_SUCCESS, hook) + for hook in error_hooks or []: + command.hooks.register(HookType.ON_ERROR, hook) + for hook in after_hooks or []: + command.hooks.register(HookType.AFTER, hook) + for hook in teardown_hooks or []: + command.hooks.register(HookType.ON_TEARDOWN, hook) + + self.commands[key] = command + self._invalidate_session_cache() + return command + + def get_bottom_row(self) -> list[str]: + """Returns the bottom row of the table for displaying additional commands.""" + bottom_row = [] + if self.history_command: + bottom_row.append(f"[{self.history_command.key}] [{self.history_command.color}]{self.history_command.description}") + if self.help_command: + bottom_row.append(f"[{self.help_command.key}] [{self.help_command.color}]{self.help_command.description}") + bottom_row.append(f"[{self.exit_command.key}] [{self.exit_command.color}]{self.exit_command.description}") + return bottom_row + + 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): + 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) + return table + + @property + def table(self) -> Table: + """Creates or returns a custom table to display the menu commands.""" + if callable(self.custom_table): + return self.custom_table(self) + elif isinstance(self.custom_table, Table): + return self.custom_table + else: + return self.build_default_table() + + def get_command(self, choice: str, from_validate=False) -> Command | None: + """Returns the selected command based on user input. Supports keys, aliases, and abbreviations.""" + choice = choice.upper() + name_map = self._name_map + + if choice in name_map: + return name_map[choice] + + prefix_matches = [cmd for key, cmd in name_map.items() if key.startswith(choice)] + if len(prefix_matches) == 1: + return prefix_matches[0] + + fuzzy_matches = get_close_matches(choice, list(name_map.keys()), n=3, cutoff=0.7) + if fuzzy_matches: + if not from_validate: + self.console.print(f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. Did you mean:[/] ") + for match in fuzzy_matches: + cmd = name_map[match] + self.console.print(f" β€’ [bold]{match}[/] β†’ {cmd.description}") + else: + if not from_validate: + self.console.print(f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]") + return None + + async def _should_run_action(self, selected_command: Command) -> bool: + if self._never_confirm: + return True + + if self.cli_args and getattr(self.cli_args, "skip_confirm", False): + return True + + if (self._always_confirm or + selected_command.confirm or + self.cli_args and getattr(self.cli_args, "force_confirm", False) + ): + if selected_command.preview_before_confirm: + await selected_command.preview() + confirm_answer = await async_confirm(selected_command.confirmation_prompt) + + if confirm_answer: + logger.info(f"[{OneColors.LIGHT_YELLOW}][{selected_command.description}]πŸ” confirmed.") + else: + logger.info(f"[{OneColors.DARK_RED}][{selected_command.description}]❌ cancelled.") + return confirm_answer + return True + + def _create_context(self, selected_command: Command) -> ExecutionContext: + """Creates a context dictionary for the selected command.""" + return ExecutionContext( + name=selected_command.description, + args=tuple(), + kwargs={}, + action=selected_command, + ) + + async def _run_action_with_spinner(self, command: Command) -> Any: + """Runs the action of the selected command with a spinner.""" + with self.console.status( + command.spinner_message, + spinner=command.spinner_type, + spinner_style=command.spinner_style, + **command.spinner_kwargs, + ): + return await command() + + async def _handle_action_error(self, selected_command: Command, error: Exception) -> bool: + """Handles errors that occur during the action of the selected command.""" + logger.exception(f"Error executing '{selected_command.description}': {error}") + self.console.print(f"[{OneColors.DARK_RED}]An error occurred while executing " + f"{selected_command.description}:[/] {error}") + if self.confirm_on_error and not self._never_confirm: + return await async_confirm("An error occurred. Do you wish to continue?") + if self._never_confirm: + return True + return False + + async def process_command(self) -> bool: + """Processes the action of the selected command.""" + 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}'.") + return True + self.last_run_command = selected_command + + if selected_command == self.exit_command: + logger.info(f"πŸ”™ Back selected: exiting {self.get_title()}") + return False + + if not await self._should_run_action(selected_command): + logger.info(f"[{OneColors.DARK_RED}] {selected_command.description} cancelled.") + return True + + context = self._create_context(selected_command) + context.start_timer() + try: + await self.hooks.trigger(HookType.BEFORE, context) + + if selected_command.spinner: + result = await self._run_action_with_spinner(selected_command) + else: + result = await selected_command() + context.result = result + await self.hooks.trigger(HookType.ON_SUCCESS, context) + except Exception as error: + context.exception = error + await self.hooks.trigger(HookType.ON_ERROR, context) + if not context.exception: + logger.info(f"βœ… Recovery hook handled error for '{selected_command.description}'") + context.result = result + else: + return await self._handle_action_error(selected_command, error) + finally: + context.stop_timer() + await self.hooks.trigger(HookType.AFTER, context) + await self.hooks.trigger(HookType.ON_TEARDOWN, context) + return True + + async def headless(self, command_key: str, return_context: bool = False) -> Any: + """Runs the action of the selected command without displaying the menu.""" + self.debug_hooks() + selected_command = self.get_command(command_key) + self.last_run_command = selected_command + + if not selected_command: + logger.info("[Headless] Back command selected. Exiting menu.") + return + + logger.info(f"[Headless] πŸš€ Running: '{selected_command.description}'") + + if not await self._should_run_action(selected_command): + raise FalyxError(f"[Headless] '{selected_command.description}' cancelled by confirmation.") + + context = self._create_context(selected_command) + context.start_timer() + try: + await self.hooks.trigger(HookType.BEFORE, context) + + if selected_command.spinner: + result = await self._run_action_with_spinner(selected_command) + else: + result = await selected_command() + context.result = result + + await self.hooks.trigger(HookType.ON_SUCCESS, context) + logger.info(f"[Headless] βœ… '{selected_command.description}' complete.") + except (KeyboardInterrupt, EOFError): + raise FalyxError(f"[Headless] ⚠️ '{selected_command.description}' interrupted by user.") + except Exception as error: + context.exception = error + await self.hooks.trigger(HookType.ON_ERROR, context) + if not context.exception: + logger.info(f"[Headless] βœ… Recovery hook handled error for '{selected_command.description}'") + return True + raise FalyxError(f"[Headless] ❌ '{selected_command.description}' failed.") from error + finally: + context.stop_timer() + await self.hooks.trigger(HookType.AFTER, context) + await self.hooks.trigger(HookType.ON_TEARDOWN, context) + + return context if return_context else context.result + + def _set_retry_policy(self, selected_command: Command) -> None: + """Sets the retry policy for the command based on CLI arguments.""" + assert isinstance(self.cli_args, Namespace), "CLI arguments must be provided." + if self.cli_args.retries or self.cli_args.retry_delay or self.cli_args.retry_backoff: + selected_command.retry_policy.enabled = True + if self.cli_args.retries: + selected_command.retry_policy.max_retries = self.cli_args.retries + if self.cli_args.retry_delay: + 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) + + def get_arg_parser(self) -> ArgumentParser: + """Returns the argument parser for the CLI.""" + parser = ArgumentParser(prog="falyx", description="Falyx CLI - Run structured async command workflows.") + parser.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging for Falyx.") + parser.add_argument("--debug-hooks", action="store_true", help="Enable default lifecycle debug logging") + parser.add_argument("--version", action="store_true", help="Show Falyx version") + subparsers = parser.add_subparsers(dest="command") + + run_parser = subparsers.add_parser("run", help="Run a specific command") + run_parser.add_argument("name", help="Key, alias, or description of the command") + run_parser.add_argument("--retries", type=int, help="Number of retries on failure", default=0) + run_parser.add_argument("--retry-delay", type=float, help="Initial delay between retries in (seconds)", default=0) + run_parser.add_argument("--retry-backoff", type=float, help="Backoff factor for retries", default=0) + run_group = run_parser.add_mutually_exclusive_group(required=False) + run_group.add_argument("-c", "--confirm", dest="force_confirm", action="store_true", help="Force confirmation prompts") + run_group.add_argument("-s", "--skip-confirm", dest="skip_confirm", action="store_true", help="Skip confirmation prompts") + + run_all_parser = subparsers.add_parser("run-all", help="Run all commands with a given tag") + run_all_parser.add_argument("-t", "--tag", required=True, help="Tag to match") + run_all_parser.add_argument("--retries", type=int, help="Number of retries on failure", default=0) + run_all_parser.add_argument("--retry-delay", type=float, help="Initial delay between retries in (seconds)", default=0) + run_all_parser.add_argument("--retry-backoff", type=float, help="Backoff factor for retries", default=0) + run_all_group = run_all_parser.add_mutually_exclusive_group(required=False) + run_all_group.add_argument("-c", "--confirm", dest="force_confirm", action="store_true", help="Force confirmation prompts") + run_all_group.add_argument("-s", "--skip-confirm", dest="skip_confirm", action="store_true", help="Skip confirmation prompts") + + preview_parser = subparsers.add_parser("preview", help="Preview a command without running it") + preview_parser.add_argument("name", help="Key, alias, or description of the command") + + subparsers.add_parser("list", help="List all available commands with tags") + + subparsers.add_parser("version", help="Show the Falyx version") + + return parser + + + async def cli(self, parser: ArgumentParser | None = None) -> None: + """Run Falyx CLI with structured subcommands.""" + parser = parser or self.get_arg_parser() + self.cli_args = parser.parse_args() + + if self.cli_args.verbose: + logging.getLogger("falyx").setLevel(logging.DEBUG) + + if self.cli_args.debug_hooks: + logger.debug("βœ… Enabling global debug hooks for all commands") + self.register_all_with_debug_hooks() + + if self.cli_args.command == "list": + await self._show_help() + sys.exit(0) + + if self.cli_args.command == "version" or self.cli_args.version: + self.console.print(f"[{OneColors.GREEN_b}]Falyx CLI v{__version__}[/]") + sys.exit(0) + + if self.cli_args.command == "preview": + command = self.get_command(self.cli_args.name) + if not command: + self.console.print(f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found.[/]") + sys.exit(1) + self.console.print(f"Preview of command '{command.key}': {command.description}") + await command.preview() + sys.exit(0) + + if self.cli_args.command == "run": + command = self.get_command(self.cli_args.name) + if not command: + self.console.print(f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found.[/]") + sys.exit(1) + self._set_retry_policy(command) + try: + result = await self.headless(self.cli_args.name) + except FalyxError as error: + self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]") + sys.exit(1) + self.console.print(f"[{OneColors.GREEN}]βœ… Result:[/] {result}") + sys.exit(0) + + if self.cli_args.command == "run-all": + matching = [ + cmd for cmd in self.commands.values() + if self.cli_args.tag.lower() in (tag.lower() for tag in cmd.tags) + ] + if not matching: + self.console.print(f"[{OneColors.LIGHT_YELLOW}]⚠️ No commands found with tag: '{self.cli_args.tag}'[/]") + sys.exit(1) + + self.console.print(f"[bold cyan]πŸš€ Running all commands with tag:[/] {self.cli_args.tag}") + for cmd in matching: + self._set_retry_policy(cmd) + await self.headless(cmd.key) + sys.exit(0) + + await self.menu() + + async def menu(self) -> None: + """Runs the menu and handles user input.""" + logger.info(f"Running menu: {self.get_title()}") + self.debug_hooks() + if self.welcome_message: + self.console.print(self.welcome_message) + while True: + self.console.print(self.table) + 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.") + break + logger.info(f"Exiting menu: {self.get_title()}") + if self.exit_message: + self.console.print(self.exit_message) diff --git a/falyx/hook_manager.py b/falyx/hook_manager.py new file mode 100644 index 0000000..e8e6ca7 --- /dev/null +++ b/falyx/hook_manager.py @@ -0,0 +1,68 @@ +"""hook_manager.py""" +from __future__ import annotations + +import inspect +from enum import Enum +from typing import Awaitable, Callable, Dict, List, Optional, Union + +from falyx.context import ExecutionContext +from falyx.utils import logger + +Hook = Union[ + Callable[[ExecutionContext], None], + Callable[[ExecutionContext], Awaitable[None]] +] + + +class HookType(Enum): + """Enum for hook types to categorize the hooks.""" + BEFORE = "before" + ON_SUCCESS = "on_success" + ON_ERROR = "on_error" + AFTER = "after" + ON_TEARDOWN = "on_teardown" + + @classmethod + def choices(cls) -> List[HookType]: + """Return a list of all hook type choices.""" + return list(cls) + + def __str__(self) -> str: + """Return the string representation of the hook type.""" + return self.value + + +class HookManager: + def __init__(self) -> None: + self._hooks: Dict[HookType, List[Hook]] = { + hook_type: [] for hook_type in HookType + } + + def register(self, hook_type: HookType, hook: Hook): + if hook_type not in HookType: + raise ValueError(f"Unsupported hook type: {hook_type}") + self._hooks[hook_type].append(hook) + + def clear(self, hook_type: Optional[HookType] = None): + if hook_type: + self._hooks[hook_type] = [] + else: + for ht in self._hooks: + self._hooks[ht] = [] + + async def trigger(self, hook_type: HookType, context: ExecutionContext): + if hook_type not in self._hooks: + raise ValueError(f"Unsupported hook type: {hook_type}") + for hook in self._hooks[hook_type]: + try: + if inspect.iscoroutinefunction(hook): + await hook(context) + else: + hook(context) + except Exception as hook_error: + logger.warning(f"⚠️ Hook '{hook.__name__}' raised an exception during '{hook_type}'" + f" for '{context.name}': {hook_error}") + + if hook_type == HookType.ON_ERROR: + assert isinstance(context.exception, BaseException) + raise context.exception from hook_error diff --git a/falyx/hooks.py b/falyx/hooks.py new file mode 100644 index 0000000..358c6dd --- /dev/null +++ b/falyx/hooks.py @@ -0,0 +1,43 @@ +"""hooks.py""" +import time + +from falyx.context import ExecutionContext +from falyx.exceptions import CircuitBreakerOpen +from falyx.utils import logger + + +class CircuitBreaker: + def __init__(self, max_failures=3, reset_timeout=10): + self.max_failures = max_failures + self.reset_timeout = reset_timeout + self.failures = 0 + self.open_until = None + + def before_hook(self, context: ExecutionContext): + name = context.name + if self.open_until: + if time.time() < self.open_until: + raise CircuitBreakerOpen(f"πŸ”΄ Circuit open for '{name}' until {time.ctime(self.open_until)}.") + else: + logger.info(f"🟒 Circuit closed again for '{name}'.") + self.failures = 0 + self.open_until = None + + def error_hook(self, context: ExecutionContext): + name = context.name + self.failures += 1 + logger.warning(f"⚠️ CircuitBreaker: '{name}' failure {self.failures}/{self.max_failures}.") + if self.failures >= self.max_failures: + self.open_until = time.time() + self.reset_timeout + logger.error(f"πŸ”΄ Circuit opened for '{name}' until {time.ctime(self.open_until)}.") + + def after_hook(self, context: ExecutionContext): + self.failures = 0 + + def is_open(self): + return self.open_until is not None and time.time() < self.open_until + + def reset(self): + self.failures = 0 + self.open_until = None + logger.info("πŸ”„ Circuit reset.") diff --git a/falyx/importer.py b/falyx/importer.py new file mode 100644 index 0000000..d062ab0 --- /dev/null +++ b/falyx/importer.py @@ -0,0 +1,29 @@ +"""importer.py""" + +import importlib +from types import ModuleType +from typing import Any, Callable + + +def resolve_action(path: str) -> Callable[..., Any]: + """ + Resolve a dotted path to a Python callable. + Example: 'mypackage.mymodule.myfunction' + + Raises: + ImportError if the module or function does not exist. + ValueError if the resolved attribute is not callable. + """ + if ":" in path: + module_path, function_name = path.split(":") + else: + *module_parts, function_name = path.split(".") + module_path = ".".join(module_parts) + + module: ModuleType = importlib.import_module(module_path) + function: Any = getattr(module, function_name) + + if not callable(function): + raise ValueError(f"Resolved attribute '{function_name}' is not callable.") + + return function diff --git a/falyx/main/main.py b/falyx/main/main.py new file mode 100644 index 0000000..41d050a --- /dev/null +++ b/falyx/main/main.py @@ -0,0 +1,87 @@ +import asyncio +import logging +from rich.markdown import Markdown + +from falyx import Action, Falyx, HookType +from falyx.hooks 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.cli()) + + +if __name__ == "__main__": + """ + Entry point for the Falyx CLI demo application. + This function initializes the menu and runs it. + """ + main() \ No newline at end of file diff --git a/falyx/retry.py b/falyx/retry.py new file mode 100644 index 0000000..c2c23c7 --- /dev/null +++ b/falyx/retry.py @@ -0,0 +1,77 @@ +"""retry.py""" +import asyncio +from pydantic import BaseModel, Field + +from falyx.action import Action +from falyx.context import ExecutionContext +from falyx.utils import logger + + +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) + enabled: bool = False + + def is_active(self) -> bool: + """ + Check if the retry policy is active. + :return: True if the retry policy is active, False otherwise. + """ + return self.max_retries > 0 and self.enabled + + +class RetryHandler: + def __init__(self, policy: RetryPolicy=RetryPolicy()): + self.policy = policy + + def enable_policy(self, backoff=2, max_retries=3, delay=1): + self.policy.enabled = True + self.policy.max_retries = max_retries + self.policy.delay = delay + self.policy.backoff = backoff + logger.info(f"πŸ”„ Retry policy enabled: {self.policy}") + + async def retry_on_error(self, context: ExecutionContext): + name = context.name + error = context.exception + target = context.action + + retries_done = 0 + current_delay = self.policy.delay + last_error = error + + if not target: + logger.warning(f"[{name}] ⚠️ No action target. Cannot retry.") + return + + if not isinstance(target, Action): + logger.warning(f"[{name}] ❌ RetryHandler only supports only supports Action objects.") + return + + if not getattr(target, "is_retryable", False): + logger.warning(f"[{name}] ❌ Not retryable.") + return + + if not self.policy.enabled: + logger.warning(f"[{name}] ❌ Retry policy is disabled.") + return + + 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}'...") + await asyncio.sleep(current_delay) + try: + result = await target.action(*context.args, **context.kwargs) + context.result = result + context.exception = None + logger.info(f"[{name}] βœ… Retry succeeded on attempt {retries_done}.") + return + 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}'.") + + context.exception = last_error + logger.error(f"[{name}] ❌ All {self.policy.max_retries} retries failed.") + return \ No newline at end of file diff --git a/falyx/themes/colors.py b/falyx/themes/colors.py new file mode 100644 index 0000000..480499d --- /dev/null +++ b/falyx/themes/colors.py @@ -0,0 +1,513 @@ +""" +colors.py + +A Python module that integrates the Nord color palette with the Rich library. +It defines a metaclass-based NordColors class allowing dynamic attribute lookups +(e.g., NORD12bu -> "#D08770 bold underline") and provides a comprehensive Nord-based +Theme that customizes Rich's default styles. + +Features: +- All core Nord colors (NORD0 through NORD15), plus named aliases (Polar Night, + Snow Storm, Frost, Aurora). +- A dynamic metaclass (NordMeta) that enables usage of 'NORD1b', 'NORD1_biudrs', etc. + to return color + bold/italic/underline/dim/reverse/strike flags for Rich. +- A ready-to-use Theme (get_nord_theme) mapping Rich's default styles to Nord colors. + +Example dynamic usage: + console.print("Hello!", style=NordColors.NORD12bu) + # => Renders "Hello!" in #D08770 (Nord12) plus bold and underline styles +""" +import re +from difflib import get_close_matches + +from rich.console import Console +from rich.style import Style +from rich.theme import Theme + + +class ColorsMeta(type): + """ + A metaclass that catches attribute lookups like `NORD12buidrs` or `ORANGE_b` and returns + a string combining the base color + bold/italic/underline/dim/reverse/strike flags. + + The color values are required to be uppercase with optional underscores and digits, + and style flags are required to be lowercase letters: + - 'b' for bold + - 'i' for italic + - 'u' for underline + - 'd' for dim + - 'r' for reverse + - 's' for strike + + Example: + 'NORD3bu' => '#4C566A bold underline' + + If an invalid base color or style flag is used, a helpful AttributeError is raised. + """ + + _STYLE_MAP = { + "b": "bold", + "i": "italic", + "u": "underline", + "d": "dim", + "r": "reverse", + "s": "strike", + } + _cache: dict = {} + + def __getattr__(cls, name: str) -> str: + """ + Intercepts attributes like 'NORD12b' or 'POLAR_NIGHT_BRIGHT_biu'. + Splits into a valid base color attribute (e.g. 'POLAR_NIGHT_BRIGHT') and suffix + characters 'b', 'i', 'u', 'd', 'r', 's' which map to 'bold', 'italic', 'underline', + 'dim', 'reverse', 'strike'. + Returns a string Rich can parse: e.g. '#3B4252 bold italic underline'. + Raises an informative AttributeError if invalid base or style flags are used. + """ + if name in cls._cache: + return cls._cache[name] + + match = re.match(r"([A-Z]+(?:_[A-Z]+)*[0-9]*)(?:_)?([biudrs]*)", name) + if not match: + raise AttributeError( + f"'{cls.__name__}' has no attribute '{name}'.\n" + f"Expected format: BASE[_]?FLAGS, where BASE is uppercase letters/underscores/digits, " + f"and FLAGS ∈ {{'b', 'i', 'u', 'd', 'r', 's'}}." + ) + + base, suffix = match.groups() + + try: + color_value = type.__getattribute__(cls, base) + except AttributeError: + error_msg = [f"'{cls.__name__}' has no color named '{base}'."] + valid_bases = [ + key for key, val in cls.__dict__.items() if isinstance(val, str) and + not key.startswith("__") + ] + suggestions = get_close_matches(base, valid_bases, n=1, cutoff=0.5) + if suggestions: + error_msg.append(f"Did you mean '{suggestions[0]}'?") + if valid_bases: + error_msg.append("Valid base color names include: " + ", ".join(valid_bases)) + raise AttributeError(" ".join(error_msg)) from None + + if not isinstance(color_value, str): + raise AttributeError( + f"'{cls.__name__}.{base}' is not a string color.\n" + f"Make sure that attribute actually contains a color string." + ) + + unique_flags = set(suffix) + styles = [] + for letter in unique_flags: + mapped_style = cls._STYLE_MAP.get(letter) + if mapped_style: + styles.append(mapped_style) + else: + raise AttributeError(f"Unknown style flag '{letter}' in attribute '{name}'") + + order = {"b": 1, "i": 2, "u": 3, "d": 4, "r": 5, "s": 6} + styles_sorted = sorted(styles, key=lambda s: order[s[0]]) + + if styles_sorted: + style_string = f"{color_value} {' '.join(styles_sorted)}" + else: + style_string = color_value + + cls._cache[name] = style_string + return style_string + + +class OneColors(metaclass=ColorsMeta): + BLACK = "#282C34" + GUTTER_GREY = "#4B5263" + COMMENT_GREY = "#5C6370" + WHITE = "#ABB2BF" + DARK_RED = "#BE5046" + LIGHT_RED = "#E06C75" + DARK_YELLOW = "#D19A66" + LIGHT_YELLOW = "#E5C07B" + GREEN = "#98C379" + CYAN = "#56B6C2" + BLUE = "#61AFEF" + MAGENTA = "#C678DD" + + + @classmethod + def as_dict(cls): + """ + Returns a dictionary mapping every attribute + (e.g. 'NORD0') to its hex code. + """ + return { + attr: getattr(cls, attr) + for attr in dir(cls) + if not callable(getattr(cls, attr)) and + not attr.startswith("__") + } + +class NordColors(metaclass=ColorsMeta): + """ + Defines the Nord color palette as class attributes. + + Each color is labeled by its canonical Nord name (NORD0-NORD15) + and also has useful aliases grouped by theme: + - Polar Night + - Snow Storm + - Frost + - Aurora + """ + + # Polar Night + NORD0 = "#2E3440" + NORD1 = "#3B4252" + NORD2 = "#434C5E" + NORD3 = "#4C566A" + + # Snow Storm + NORD4 = "#D8DEE9" + NORD5 = "#E5E9F0" + NORD6 = "#ECEFF4" + + # Frost + NORD7 = "#8FBCBB" + NORD8 = "#88C0D0" + NORD9 = "#81A1C1" + NORD10 = "#5E81AC" + + # Aurora + NORD11 = "#BF616A" + NORD12 = "#D08770" + NORD13 = "#EBCB8B" + NORD14 = "#A3BE8C" + NORD15 = "#B48EAD" + + POLAR_NIGHT_ORIGIN = NORD0 + POLAR_NIGHT_BRIGHT = NORD1 + POLAR_NIGHT_BRIGHTER = NORD2 + POLAR_NIGHT_BRIGHTEST = NORD3 + + SNOW_STORM_BRIGHT = NORD4 + SNOW_STORM_BRIGHTER = NORD5 + SNOW_STORM_BRIGHTEST = NORD6 + + FROST_TEAL = NORD7 + FROST_ICE = NORD8 + FROST_SKY = NORD9 + FROST_DEEP = NORD10 + + RED = NORD11 + ORANGE = NORD12 + YELLOW = NORD13 + GREEN = NORD14 + PURPLE = NORD15 + MAGENTA = NORD15 + BLUE = NORD10 + CYAN = NORD8 + + @classmethod + def as_dict(cls): + """ + Returns a dictionary mapping every NORD* attribute + (e.g. 'NORD0') to its hex code. + """ + return { + attr: getattr(cls, attr) + for attr in dir(cls) + if attr.startswith("NORD") and + not callable(getattr(cls, attr)) + } + + @classmethod + def aliases(cls): + """ + Returns a dictionary of *all* other aliases + (Polar Night, Snow Storm, Frost, Aurora). + """ + skip_prefixes = ("NORD", "__") + alias_names = [ + attr for attr in dir(cls) + if not any(attr.startswith(sp) for sp in skip_prefixes) + and not callable(getattr(cls, attr)) + ] + return {name: getattr(cls, name) for name in alias_names} + + +NORD_THEME_STYLES: dict[str, Style] = { + # --------------------------------------------------------------- + # Base / Structural styles + # --------------------------------------------------------------- + "none": Style.null(), + "reset": Style( + color="default", + bgcolor="default", + dim=False, + bold=False, + italic=False, + underline=False, + blink=False, + blink2=False, + reverse=False, + conceal=False, + strike=False, + ), + "dim": Style(dim=True), + "bright": Style(dim=False), + "bold": Style(bold=True), + "strong": Style(bold=True), + "code": Style(reverse=True, bold=True), + "italic": Style(italic=True), + "emphasize": Style(italic=True), + "underline": Style(underline=True), + "blink": Style(blink=True), + "blink2": Style(blink2=True), + "reverse": Style(reverse=True), + "strike": Style(strike=True), + + # --------------------------------------------------------------- + # Basic color names mapped to Nord + # --------------------------------------------------------------- + "black": Style(color=NordColors.POLAR_NIGHT_ORIGIN), + "red": Style(color=NordColors.RED), + "green": Style(color=NordColors.GREEN), + "yellow": Style(color=NordColors.YELLOW), + "magenta": Style(color=NordColors.MAGENTA), + "purple": Style(color=NordColors.PURPLE), + "cyan": Style(color=NordColors.CYAN), + "blue": Style(color=NordColors.BLUE), + "white": Style(color=NordColors.SNOW_STORM_BRIGHTEST), + + # --------------------------------------------------------------- + # Inspect + # --------------------------------------------------------------- + "inspect.attr": Style(color=NordColors.YELLOW, italic=True), + "inspect.attr.dunder": Style(color=NordColors.YELLOW, italic=True, dim=True), + "inspect.callable": Style(bold=True, color=NordColors.RED), + "inspect.async_def": Style(italic=True, color=NordColors.FROST_ICE), + "inspect.def": Style(italic=True, color=NordColors.FROST_ICE), + "inspect.class": Style(italic=True, color=NordColors.FROST_ICE), + "inspect.error": Style(bold=True, color=NordColors.RED), + "inspect.equals": Style(), + "inspect.help": Style(color=NordColors.FROST_ICE), + "inspect.doc": Style(dim=True), + "inspect.value.border": Style(color=NordColors.GREEN), + + # --------------------------------------------------------------- + # Live / Layout + # --------------------------------------------------------------- + "live.ellipsis": Style(bold=True, color=NordColors.RED), + "layout.tree.row": Style(dim=False, color=NordColors.RED), + "layout.tree.column": Style(dim=False, color=NordColors.FROST_DEEP), + + # --------------------------------------------------------------- + # Logging + # --------------------------------------------------------------- + "logging.keyword": Style(bold=True, color=NordColors.YELLOW), + "logging.level.notset": Style(dim=True), + "logging.level.debug": Style(color=NordColors.GREEN), + "logging.level.info": Style(color=NordColors.FROST_ICE), + "logging.level.warning": Style(color=NordColors.RED), + "logging.level.error": Style(color=NordColors.RED, bold=True), + "logging.level.critical": Style(color=NordColors.RED, bold=True, reverse=True), + "log.level": Style.null(), + "log.time": Style(color=NordColors.FROST_ICE, dim=True), + "log.message": Style.null(), + "log.path": Style(dim=True), + + # --------------------------------------------------------------- + # Python repr + # --------------------------------------------------------------- + "repr.ellipsis": Style(color=NordColors.YELLOW), + "repr.indent": Style(color=NordColors.GREEN, dim=True), + "repr.error": Style(color=NordColors.RED, bold=True), + "repr.str": Style(color=NordColors.GREEN, italic=False, bold=False), + "repr.brace": Style(bold=True), + "repr.comma": Style(bold=True), + "repr.ipv4": Style(bold=True, color=NordColors.GREEN), + "repr.ipv6": Style(bold=True, color=NordColors.GREEN), + "repr.eui48": Style(bold=True, color=NordColors.GREEN), + "repr.eui64": Style(bold=True, color=NordColors.GREEN), + "repr.tag_start": Style(bold=True), + "repr.tag_name": Style(color=NordColors.PURPLE, bold=True), + "repr.tag_contents": Style(color="default"), + "repr.tag_end": Style(bold=True), + "repr.attrib_name": Style(color=NordColors.YELLOW, italic=False), + "repr.attrib_equal": Style(bold=True), + "repr.attrib_value": Style(color=NordColors.PURPLE, italic=False), + "repr.number": Style(color=NordColors.FROST_ICE, bold=True, italic=False), + "repr.number_complex": Style(color=NordColors.FROST_ICE, bold=True, italic=False), + "repr.bool_true": Style(color=NordColors.GREEN, italic=True), + "repr.bool_false": Style(color=NordColors.RED, italic=True), + "repr.none": Style(color=NordColors.PURPLE, italic=True), + "repr.url": Style(underline=True, color=NordColors.FROST_ICE, italic=False, bold=False), + "repr.uuid": Style(color=NordColors.YELLOW, bold=False), + "repr.call": Style(color=NordColors.PURPLE, bold=True), + "repr.path": Style(color=NordColors.PURPLE), + "repr.filename": Style(color=NordColors.PURPLE), + + # --------------------------------------------------------------- + # Rule + # --------------------------------------------------------------- + "rule.line": Style(color=NordColors.GREEN), + "rule.text": Style.null(), + + # --------------------------------------------------------------- + # JSON + # --------------------------------------------------------------- + "json.brace": Style(bold=True), + "json.bool_true": Style(color=NordColors.GREEN, italic=True), + "json.bool_false": Style(color=NordColors.RED, italic=True), + "json.null": Style(color=NordColors.PURPLE, italic=True), + "json.number": Style(color=NordColors.FROST_ICE, bold=True, italic=False), + "json.str": Style(color=NordColors.GREEN, italic=False, bold=False), + "json.key": Style(color=NordColors.FROST_ICE, bold=True), + + # --------------------------------------------------------------- + # Prompt + # --------------------------------------------------------------- + "prompt": Style.null(), + "prompt.choices": Style(color=NordColors.PURPLE, bold=True), + "prompt.default": Style(color=NordColors.FROST_ICE, bold=True), + "prompt.invalid": Style(color=NordColors.RED), + "prompt.invalid.choice": Style(color=NordColors.RED), + + # --------------------------------------------------------------- + # Pretty + # --------------------------------------------------------------- + "pretty": Style.null(), + + # --------------------------------------------------------------- + # Scope + # --------------------------------------------------------------- + "scope.border": Style(color=NordColors.FROST_ICE), + "scope.key": Style(color=NordColors.YELLOW, italic=True), + "scope.key.special": Style(color=NordColors.YELLOW, italic=True, dim=True), + "scope.equals": Style(color=NordColors.RED), + + # --------------------------------------------------------------- + # Table + # --------------------------------------------------------------- + "table.header": Style(bold=True), + "table.footer": Style(bold=True), + "table.cell": Style.null(), + "table.title": Style(italic=True), + "table.caption": Style(italic=True, dim=True), + + # --------------------------------------------------------------- + # Traceback + # --------------------------------------------------------------- + "traceback.error": Style(color=NordColors.RED, italic=True), + "traceback.border.syntax_error": Style(color=NordColors.RED), + "traceback.border": Style(color=NordColors.RED), + "traceback.text": Style.null(), + "traceback.title": Style(color=NordColors.RED, bold=True), + "traceback.exc_type": Style(color=NordColors.RED, bold=True), + "traceback.exc_value": Style.null(), + "traceback.offset": Style(color=NordColors.RED, bold=True), + + # --------------------------------------------------------------- + # Progress bars + # --------------------------------------------------------------- + "bar.back": Style(color=NordColors.POLAR_NIGHT_BRIGHTEST), + "bar.complete": Style(color=NordColors.RED), + "bar.finished": Style(color=NordColors.GREEN), + "bar.pulse": Style(color=NordColors.RED), + "progress.description": Style.null(), + "progress.filesize": Style(color=NordColors.GREEN), + "progress.filesize.total": Style(color=NordColors.GREEN), + "progress.download": Style(color=NordColors.GREEN), + "progress.elapsed": Style(color=NordColors.YELLOW), + "progress.percentage": Style(color=NordColors.PURPLE), + "progress.remaining": Style(color=NordColors.FROST_ICE), + "progress.data.speed": Style(color=NordColors.RED), + "progress.spinner": Style(color=NordColors.GREEN), + "status.spinner": Style(color=NordColors.GREEN), + + # --------------------------------------------------------------- + # Tree + # --------------------------------------------------------------- + "tree": Style(), + "tree.line": Style(), + + # --------------------------------------------------------------- + # Markdown + # --------------------------------------------------------------- + "markdown.paragraph": Style(), + "markdown.text": Style(), + "markdown.em": Style(italic=True), + "markdown.emph": Style(italic=True), # For commonmark compatibility + "markdown.strong": Style(bold=True), + "markdown.code": Style(bold=True, color=NordColors.FROST_ICE, bgcolor=NordColors.POLAR_NIGHT_ORIGIN), + "markdown.code_block": Style(color=NordColors.FROST_ICE, bgcolor=NordColors.POLAR_NIGHT_ORIGIN), + "markdown.block_quote": Style(color=NordColors.PURPLE), + "markdown.list": Style(color=NordColors.FROST_ICE), + "markdown.item": Style(), + "markdown.item.bullet": Style(color=NordColors.YELLOW, bold=True), + "markdown.item.number": Style(color=NordColors.YELLOW, bold=True), + "markdown.hr": Style(color=NordColors.YELLOW), + "markdown.h1.border": Style(), + "markdown.h1": Style(bold=True), + "markdown.h2": Style(bold=True, underline=True), + "markdown.h3": Style(bold=True), + "markdown.h4": Style(bold=True, dim=True), + "markdown.h5": Style(underline=True), + "markdown.h6": Style(italic=True), + "markdown.h7": Style(italic=True, dim=True), + "markdown.link": Style(color=NordColors.FROST_ICE), + "markdown.link_url": Style(color=NordColors.FROST_SKY, underline=True), + "markdown.s": Style(strike=True), + + # --------------------------------------------------------------- + # ISO8601 + # --------------------------------------------------------------- + "iso8601.date": Style(color=NordColors.FROST_ICE), + "iso8601.time": Style(color=NordColors.PURPLE), + "iso8601.timezone": Style(color=NordColors.YELLOW), +} + + +def get_nord_theme() -> Theme: + """ + Returns a Rich Theme for the Nord color palette. + """ + return Theme(NORD_THEME_STYLES) + + +if __name__ == "__main__": + console = Console(theme=get_nord_theme(), color_system="truecolor") + + # Basic demonstration of the Nord theme + console.print("Welcome to the [bold underline]Nord Themed[/] console!\n") + + console.print("1) This is default text (no style).") + console.print("2) This is [red]red[/].") + console.print("3) This is [green]green[/].") + console.print("4) This is [blue]blue[/] (maps to Frost).") + console.print("5) And here's some [bold]Bold text[/] and [italic]italic text[/].\n") + + console.log("Log example in info mode.") + console.log("Another log, with a custom style", style="logging.level.warning") + + # Demonstrate the dynamic attribute usage + console.print( + "6) Demonstrating dynamic attribute [NORD3bu]: This text should be bold, underlined, " + "and use Nord3's color (#4C566A).", + style=NordColors.NORD3bu, + ) + console.print() + + # Show how the custom attribute can fail gracefully + try: + console.print("7) Attempting invalid suffix [NORD3z]:", style=NordColors.NORD3z) + except AttributeError as error: + console.print(f"Caught error: {error}", style="red") + + # Demonstrate a traceback style: + console.print("\n8) Raising and displaying a traceback with Nord styling:\n", style="bold") + try: + raise ValueError("Nord test exception!") + except ValueError: + console.print_exception(show_locals=True) + + console.print("\nEnd of Nord theme demo!", style="bold") diff --git a/falyx/utils.py b/falyx/utils.py new file mode 100644 index 0000000..b205f4d --- /dev/null +++ b/falyx/utils.py @@ -0,0 +1,192 @@ +"""utils.py""" +import functools +import inspect +import logging +import os +from itertools import islice +from typing import Any, Awaitable, Callable, TypeVar + +import pythonjsonlogger.json +from prompt_toolkit import PromptSession +from prompt_toolkit.formatted_text import (AnyFormattedText, FormattedText, + merge_formatted_text) +from rich.logging import RichHandler + +from falyx.themes.colors import OneColors + +logger = logging.getLogger("falyx") + +T = TypeVar("T") + +async def _noop(*args, **kwargs): + pass + +def is_coroutine(function: Callable[..., Any]) -> bool: + return inspect.iscoroutinefunction(function) + + +def ensure_async(function: Callable[..., T]) -> Callable[..., Awaitable[T]]: + if is_coroutine(function): + return function # type: ignore + + @functools.wraps(function) + async def async_wrapper(*args, **kwargs) -> T: + return function(*args, **kwargs) + return async_wrapper + + +def chunks(iterator, size): + """Yield successive n-sized chunks from an iterator.""" + iterator = iter(iterator) + while True: + chunk = list(islice(iterator, size)) + if not chunk: + break + yield chunk + + +async def async_confirm(message: AnyFormattedText = "Are you sure?") -> bool: + session: PromptSession = PromptSession() + while True: + merged_message: AnyFormattedText = merge_formatted_text([message, FormattedText([(OneColors.LIGHT_YELLOW_b, " [Y/n] ")])]) + answer: str = (await session.prompt_async(merged_message)).strip().lower() + if answer in ("y", "yes"): + return True + if answer in ("n", "no", ""): + return False + print("Please enter y or n.") + + +class CaseInsensitiveDict(dict): + """A case-insensitive dictionary that treats all keys as uppercase.""" + + def __setitem__(self, key, value): + super().__setitem__(key.upper(), value) + + def __getitem__(self, key): + return super().__getitem__(key.upper()) + + def __contains__(self, key): + return super().__contains__(key.upper()) + + def get(self, key, default=None): + return super().get(key.upper(), default) + + def pop(self, key, default=None): + return super().pop(key.upper(), default) + + def update(self, other=None, **kwargs): + if other: + other = {k.upper(): v for k, v in other.items()} + kwargs = {k.upper(): v for k, v in kwargs.items()} + super().update(other, **kwargs) + + +def running_in_container() -> bool: + try: + with open("/proc/1/cgroup", "r", encoding="UTF-8") as f: + content = f.read() + return ( + "docker" in content + or "kubepods" in content + or "containerd" in content + or "podman" in content + ) + except Exception: + return False + + +def setup_logging( + mode: str | None = None, + log_filename: str = "falyx.log", + json_log_to_file: bool = False, + file_log_level: int = logging.DEBUG, + console_log_level: int = logging.WARNING, +): + """ + Configure logging for Falyx with support for both CLI-friendly and structured JSON output. + + This function sets up separate logging handlers for console and file output, with optional + support for JSON formatting. It also auto-detects whether the application is running inside + a container to default to machine-readable logs when appropriate. + + Args: + mode (str | None): + Logging output mode. Can be: + - "cli": human-readable Rich console logs (default outside containers) + - "json": machine-readable JSON logs (default inside containers) + If not provided, it will use the `FALYX_LOG_MODE` environment variable + or fallback based on container detection. + log_filename (str): + Path to the log file for file-based logging output. Defaults to "falyx.log". + json_log_to_file (bool): + Whether to format file logs as JSON (structured) instead of plain text. + Defaults to False. + file_log_level (int): + Logging level for file output. Defaults to `logging.DEBUG`. + console_log_level (int): + Logging level for console output. Defaults to `logging.WARNING`. + + Behavior: + - Clears existing root handlers before setup. + - Configures console logging using either Rich (for CLI) or JSON formatting. + - Configures file logging in plain text or JSON based on `json_log_to_file`. + - Automatically sets logging levels for noisy third-party modules (`urllib3`, `asyncio`). + - Propagates logs from the "falyx" logger to ensure centralized output. + + Raises: + ValueError: If an invalid logging `mode` is passed. + + Environment Variables: + FALYX_LOG_MODE: Can override `mode` to enforce "cli" or "json" logging behavior. + """ + if not mode: + mode = os.getenv("FALYX_LOG_MODE") or ( + "json" if running_in_container() else "cli" + ) + + root = logging.getLogger() + root.setLevel(logging.DEBUG) + if root.hasHandlers(): + root.handlers.clear() + + if mode == "cli": + console_handler: RichHandler | logging.StreamHandler = RichHandler( + rich_tracebacks=True, + show_time=True, + show_level=True, + show_path=False, + markup=True, + log_time_format="[%Y-%m-%d %H:%M:%S]", + ) + elif mode == "json": + console_handler = logging.StreamHandler() + console_handler.setFormatter( + pythonjsonlogger.json.JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s") + ) + else: + raise ValueError(f"Invalid log mode: {mode}") + + console_handler.setLevel(console_log_level) + root.addHandler(console_handler) + + file_handler = logging.FileHandler(log_filename) + file_handler.setLevel(file_log_level) + if json_log_to_file: + file_handler.setFormatter( + pythonjsonlogger.json.JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s") + ) + else: + file_handler.setFormatter(logging.Formatter( + "%(asctime)s [%(name)s] [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + )) + root.addHandler(file_handler) + + logging.getLogger("urllib3").setLevel(logging.WARNING) + logging.getLogger("asyncio").setLevel(logging.WARNING) + logging.getLogger("markdown_it").setLevel(logging.WARNING) + + logger = logging.getLogger("falyx") + logger.propagate = True + logger.debug("Logging initialized in '%s' mode.", mode) diff --git a/falyx/version.py b/falyx/version.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/falyx/version.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..50be700 --- /dev/null +++ b/pylintrc @@ -0,0 +1,430 @@ +# This Pylint rcfile contains a best-effort configuration to uphold the +# best-practices and style described in the Google Python style guide: +# https://google.github.io/styleguide/pyguide.html +# +# Its canonical open-source location is: +# https://google.github.io/styleguide/pylintrc + +[MASTER] + +# Files or directories to be skipped. They should be base names, not paths. +ignore=third_party + +# Files or directories matching the regex patterns are skipped. The regex +# matches against base names, not paths. +ignore-patterns= + +# Pickle collected data for later comparisons. +persistent=no + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Use multiple processes to speed up Pylint. +jobs=4 + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +#enable= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=abstract-method, + apply-builtin, + arguments-differ, + attribute-defined-outside-init, + backtick, + bad-option-value, + basestring-builtin, + buffer-builtin, + c-extension-no-member, + consider-using-enumerate, + cmp-builtin, + cmp-method, + coerce-builtin, + coerce-method, + delslice-method, + div-method, + duplicate-code, + eq-without-hash, + execfile-builtin, + file-builtin, + filter-builtin-not-iterating, + fixme, + getslice-method, + global-statement, + hex-method, + idiv-method, + implicit-str-concat, + import-error, + import-self, + import-star-module-level, + inconsistent-return-statements, + input-builtin, + intern-builtin, + invalid-str-codec, + locally-disabled, + long-builtin, + long-suffix, + map-builtin-not-iterating, + misplaced-comparison-constant, + missing-function-docstring, + metaclass-assignment, + next-method-called, + next-method-defined, + no-absolute-import, + no-else-break, + no-else-continue, + no-else-raise, + no-else-return, + no-init, # added + no-member, + no-name-in-module, + no-self-use, + nonzero-method, + oct-method, + old-division, + old-ne-operator, + old-octal-literal, + old-raise-syntax, + parameter-unpacking, + print-statement, + raising-string, + range-builtin-not-iterating, + raw_input-builtin, + rdiv-method, + reduce-builtin, + relative-import, + reload-builtin, + round-builtin, + setslice-method, + signature-differs, + standarderror-builtin, + suppressed-message, + sys-max-int, + too-few-public-methods, + too-many-ancestors, + too-many-arguments, + too-many-boolean-expressions, + too-many-branches, + too-many-instance-attributes, + too-many-locals, + too-many-nested-blocks, + too-many-public-methods, + too-many-return-statements, + too-many-statements, + trailing-newlines, + unichr-builtin, + unicode-builtin, + unnecessary-pass, + unpacking-in-except, + useless-else-on-loop, + useless-object-inheritance, + useless-suppression, + using-cmp-argument, + wrong-import-order, + xrange-builtin, + zip-builtin-not-iterating, + broad-exception-caught + + +[REPORTS] + +# Set the output format. Available formats are text, parseable, colorized, msvs +# (visual studio) and html. You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages +reports=no + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + + +[BASIC] + +# Good variable names which should always be accepted, separated by a comma +good-names=main,_ + +# Bad variable names which should always be refused, separated by a comma +bad-names= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty,cached_property.cached_property,cached_property.threaded_cached_property,cached_property.cached_property_with_ttl,cached_property.threaded_cached_property_with_ttl + +# Regular expression matching correct function names +function-rgx=^(?:(?PsetUp|tearDown|setUpModule|tearDownModule)|(?P_?[A-Z][a-zA-Z0-9]*)|(?P_?[a-z][a-z0-9_]*))$ + +# Regular expression matching correct variable names +variable-rgx=^[a-z][a-z0-9_]*$ + +# Regular expression matching correct constant names +const-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ + +# Regular expression matching correct attribute names +attr-rgx=^_{0,2}[a-z][a-z0-9_]*$ + +# Regular expression matching correct argument names +argument-rgx=^[a-z][a-z0-9_]*$ + +# Regular expression matching correct class attribute names +class-attribute-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ + +# Regular expression matching correct inline iteration names +inlinevar-rgx=^[a-z][a-z0-9_]*$ + +# Regular expression matching correct class names +class-rgx=^_?[A-Z][a-zA-Z0-9]*$ + +# Regular expression matching correct module names +module-rgx=^(_?[a-z][a-z0-9_]*|__init__)$ + +# Regular expression matching correct method names +method-rgx=(?x)^(?:(?P_[a-z0-9_]+__|runTest|setUp|tearDown|setUpTestCase|tearDownTestCase|setupSelf|tearDownClass|setUpClass|(test|assert)_*[A-Z0-9][a-zA-Z0-9_]*|next)|(?P_{0,2}[A-Z][a-zA-Z0-9_]*)|(?P_{0,2}[a-z][a-z0-9_]*))$ + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=(__.*__|main|test.*|.*test|.*Test)$ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=10 + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager,contextlib2.contextmanager + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + + +[FORMAT] + +# Maximum number of characters on a single line. +max-line-length=80 + +# TODO(https://github.com/PyCQA/pylint/issues/3352): Direct pylint to exempt +# lines made too long by directives to pytype. + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=(?x)( + ^\s*(\#\ )??$| + ^\s*(from\s+\S+\s+)?import\s+.+$) + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=yes + +# Maximum number of lines in a module +max-module-lines=99999 + +# String used as indentation unit. The internal Google style guide mandates 2 +# spaces. Google's externaly-published style guide says 4, consistent with +# PEP 8. Here, we use 2 spaces, for conformity with many open-sourced Google +# projects (like TensorFlow). +indent-string=' ' + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=TODO + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=yes + + +[VARIABLES] + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=^\*{0,2}(_$|unused_|dummy_) + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_,_cb + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six,six.moves,past.builtins,future.builtins,functools + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging,absl.logging,tensorflow.io.logging + + +[SIMILARITIES] + +# Minimum lines number of a similarity. +min-similarity-lines=4 + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + + +[SPELLING] + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[IMPORTS] + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub, + TERMIOS, + Bastion, + rexec, + sets + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant, absl + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls, + class_ + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=builtins.StandardError, + builtins.Exception, + builtins.BaseException diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c2f5e57 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,35 @@ +[tool.poetry] +name = "falyx" +version = "0.1.0" +description = "Reliable and introspectable async CLI action framework." +authors = ["Roland Thomas Jr "] +license = "MIT" +readme = "README.md" +packages = [{ include = "falyx" }] + +[tool.poetry.dependencies] +python = ">=3.10" +prompt_toolkit = "^3.0" +rich = "^13.0" +pydantic = "^2.0" + +[tool.poetry.group.dev.dependencies] +pytest = "^7.0" +pytest-asyncio = "^0.20" +ruff = "^0.3" + +[tool.poetry.scripts] +falyx = "falyx.cli.main:main" +sync-version = "scripts.sync_version:main" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.pylint."MESSAGES CONTROL"] +disable = ["broad-exception-caught"] diff --git a/scripts/sync_version.py b/scripts/sync_version.py new file mode 100644 index 0000000..1b9ef24 --- /dev/null +++ b/scripts/sync_version.py @@ -0,0 +1,17 @@ +"""scripts/sync_version.py""" + +import toml +from pathlib import Path + +def main(): + pyproject_path = Path(__file__).parent.parent / "pyproject.toml" + version_path = Path(__file__).parent.parent / "falyx" / "version.py" + + data = toml.load(pyproject_path) + version = data["tool"]["poetry"]["version"] + + version_path.write_text(f'__version__ = "{version}"\n') + print(f"βœ… Synced version: {version} β†’ {version_path}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e575752 --- /dev/null +++ b/setup.py @@ -0,0 +1,18 @@ +from setuptools import setup, find_packages + +setup( + name="falyx", + version="0.0.1", + description="Reserved package name for future CLI framework.", + long_description=open("README.md").read(), + long_description_content_type="text/markdown", + author="Roland Thomas Jr", + author_email="roland@rtj.dev", + packages=find_packages(), + python_requires=">=3.10", + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Development Status :: 1 - Planning", + ], +) diff --git a/tests/test_actions.py b/tests/test_actions.py new file mode 100644 index 0000000..89ee8b0 --- /dev/null +++ b/tests/test_actions.py @@ -0,0 +1,230 @@ +import pytest +import asyncio +import pickle +import warnings +from falyx.action import Action, ChainedAction, ActionGroup, ProcessAction +from falyx.execution_registry import ExecutionRegistry as er +from falyx.hook_manager import HookManager, HookType +from falyx.context import ExecutionContext, ResultsContext + +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() + hm.register(HookType.BEFORE, capturing_hook) + return hm + +@pytest.fixture(autouse=True) +def clean_registry(): + er.clear() + yield + er.clear() + +# --- Tests --- + +@pytest.mark.asyncio +async def test_action_runs_correctly(sample_action): + result = await sample_action() + assert result == 6 + +@pytest.mark.asyncio +async def test_action_hook_lifecycle(hook_manager): + action = Action( + name="hooked", + action=lambda: 42, + hooks=hook_manager + ) + + await action() + + context = er.get_latest() + assert context.name == "hooked" + assert context.extra.get("hook_triggered") is True + +@pytest.mark.asyncio +async def test_chained_action_with_result_injection(): + 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) + ] + chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True) + result = await chain() + assert result == [1, 6, 12] + +@pytest.mark.asyncio +async def test_action_group_runs_in_parallel(): + actions = [ + Action(name="a", action=lambda: 1), + Action(name="b", action=lambda: 2), + Action(name="c", action=lambda: 3), + ] + group = ActionGroup(name="parallel", actions=actions) + result = await group() + result_dict = dict(result) + assert result_dict == {"a": 1, "b": 2, "c": 3} + +@pytest.mark.asyncio +async def test_chained_action_inject_from_action(): + 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), + ] + ) + actions = [ + Action(name="first", action=lambda: 1), + Action(name="second", action=lambda last_result: last_result + 2, inject_last_result=True), + inner_chain, + + ] + outer_chain = ChainedAction(name="test_chain", actions=actions) + result = await outer_chain() + assert result == [1, 3, [13, 18]] + +@pytest.mark.asyncio +async def test_chained_action_with_group(): + 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), + ] + ) + actions = [ + Action(name="first", action=lambda: 1), + Action(name="second", action=lambda last_result: last_result + 2, inject_last_result=True), + group, + ] + chain = ChainedAction(name="test_chain", actions=actions) + result = await chain() + assert result == [1, 3, [("a", 4), ("b", 5), ("c", 3)]] + +@pytest.mark.asyncio +async def test_action_error_triggers_error_hook(): + def fail(): + raise ValueError("boom") + + hooks = HookManager() + flag = {} + + async def error_hook(ctx): + flag["called"] = True + + hooks.register(HookType.ON_ERROR, error_hook) + action = Action(name="fail_action", action=fail, hooks=hooks) + + with pytest.raises(ValueError): + await action() + + assert flag.get("called") is True + +@pytest.mark.asyncio +async def test_chained_action_rollback_on_failure(): + rollback_called = [] + + async def success(): + return "ok" + + async def fail(): + raise RuntimeError("fail") + + async def rollback_fn(): + rollback_called.append("rolled back") + + actions = [ + Action(name="ok", action=success, rollback=rollback_fn), + Action(name="fail", action=fail, rollback=rollback_fn) + ] + + chain = ChainedAction(name="chain", actions=actions) + + with pytest.raises(RuntimeError): + await chain() + + 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}) + + chain = ChainedAction(name="chain", actions=[ + Action(name="a", action=lambda: 1), + Action(name="b", action=lambda: 2), + ]) + chain.register_hooks_recursively(HookType.BEFORE, hook) + + await chain() + + for ctx in er.get_by_name("a") + er.get_by_name("b"): + assert ctx.extra.get("test_marker") is True + +@pytest.mark.asyncio +async def test_action_hook_recovers_error(): + async def flaky(): + raise ValueError("fail") + + async def recovery_hook(ctx): + ctx.result = 99 + ctx.exception = None + + hooks = HookManager() + hooks.register(HookType.ON_ERROR, recovery_hook) + action = Action(name="recovering", action=flaky, hooks=hooks) + + result = await action() + assert result == 99 + +@pytest.mark.asyncio +async def test_action_group_injects_last_result(): + 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, + ]) + result = await chain() + result_dict = dict(result[1]) + assert result_dict == {"g1": 15, "g2": 25}