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}