diff --git a/README.md b/README.md index 57bc51e..07aae69 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ - βš™οΈ 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 +- 🧩 Extensible CLI menus, customizable bottom bars, and keyboard shortcuts > Built for developers who value *clarity*, *resilience*, and *visibility* in their terminal workflows. @@ -21,12 +21,13 @@ 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 +- Inject the result of one step into the next (`last_result` / `auto_inject`) +- Handle flaky operations with retries, backoff, and jitter - Roll back safely on failure with structured undo logic -- Add observability with execution timing, result tracking, and hooks +- Add observability with timing, tracebacks, and lifecycle hooks - Run in both interactive *and* headless (scriptable) modes -- Customize output with Rich `Table`s (grouping, theming, etc.) +- Support config-driven workflows with YAML or TOML +- Visualize tagged command groups and menu state via Rich tables --- @@ -60,6 +61,7 @@ async def flaky_step(): await asyncio.sleep(0.2) if random.random() < 0.5: raise RuntimeError("Random failure!") + print("ok") return "ok" # Create the actions @@ -78,6 +80,8 @@ falyx.add_command( preview_before_confirm=True, confirm=True, retry_all=True, + spinner=True, + style="cyan", ) # Entry point @@ -86,76 +90,131 @@ if __name__ == "__main__": ``` ```bash -❯ python simple.py - πŸš€ Falyx Demo +$ python simple.py + πŸš€ Falyx Demo - [R] Run My Pipeline - [Y] History [Q] Exit + [R] Run My Pipeline + [H] Help [Y] History [X] Exit > ``` ```bash -❯ python simple.py run R +$ python simple.py run r Command: 'R' β€” Run My Pipeline └── β›“ ChainedAction 'my_pipeline' β”œβ”€β”€ βš™ Action 'step_1' β”‚ ↻ Retries: 3x, delay 1.0s, backoff 2.0x └── βš™ Action 'step_2' ↻ Retries: 3x, delay 1.0s, backoff 2.0x -Confirm execution of R β€” Run My Pipeline (calls `my_pipeline`) [Y/n] y -[2025-04-15 22:03:57] WARNING ⚠️ Retry attempt 1/3 failed due to 'Random failure!'. -βœ… Result: ['ok', 'ok'] +❓ Confirm execution of R β€” Run My Pipeline (calls `my_pipeline`) [Y/n] > y +[2025-07-20 09:29:35] WARNING Retry attempt 1/3 failed due to 'Random failure!'. +ok +[2025-07-20 09:29:38] WARNING Retry attempt 1/3 failed due to 'Random failure!'. +ok ``` --- ## πŸ“¦ 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` +- βœ… Async-native `Action`, `ChainedAction`, `ActionGroup`, `ProcessAction` +- πŸ” Retry policies with delay, backoff, jitter β€” opt-in per action or globally +- β›“ Rollbacks and lifecycle hooks for chained execution +- πŸŽ›οΈ Headless or interactive CLI powered by `argparse` + `prompt_toolkit` +- πŸ“Š In-memory `ExecutionRegistry` with result tracking, timing, and tracebacks +- 🌐 CLI menu construction via config files or Python +- ⚑ Bottom bar toggle switches and counters with `Ctrl+` shortcuts +- πŸ” Structured confirmation prompts and help rendering +- πŸͺ΅ Flexible logging: Rich console for devs, JSON logs for ops --- -## πŸ” Execution Trace +### 🧰 Building Blocks -```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'] +- **`Action`**: A single unit of async (or sync) logic +- **`ChainedAction`**: Execute a sequence of actions, with rollback and injection +- **`ActionGroup`**: Run actions concurrently and collect results +- **`ProcessAction`**: Use `multiprocessing` for CPU-bound workflows +- **`Falyx`**: Interactive or headless CLI controller with history, menus, and theming +- **`ExecutionContext`**: Metadata store per invocation (name, args, result, timing) +- **`HookManager`**: Attach `before`, `after`, `on_success`, `on_error`, `on_teardown` + +--- + +### πŸ” Logging +``` +2025-07-20 09:29:32 [falyx] [INFO] Command 'R' selected. +2025-07-20 09:29:32 [falyx] [INFO] [run_key] Executing: R β€” Run My Pipeline +2025-07-20 09:29:33 [falyx] [INFO] [my_pipeline] Starting -> ChainedAction(name=my_pipeline, actions=['step_1', 'step_2'], args=(), kwargs={}, auto_inject=False, return_list=False)() +2025-07-20 09:29:33 [falyx] [INFO] [step_1] Retrying (1/3) in 1.0s due to 'Random failure!'... +2025-07-20 09:29:35 [falyx] [WARNING] [step_1] Retry attempt 1/3 failed due to 'Random failure!'. +2025-07-20 09:29:35 [falyx] [INFO] [step_1] Retrying (2/3) in 2.0s due to 'Random failure!'... +2025-07-20 09:29:37 [falyx] [INFO] [step_1] Retry succeeded on attempt 2. +2025-07-20 09:29:37 [falyx] [INFO] [step_1] Recovered: step_1 +2025-07-20 09:29:37 [falyx] [DEBUG] [step_1] status=OK duration=3.627s result='ok' exception=None +2025-07-20 09:29:37 [falyx] [INFO] [step_2] Retrying (1/3) in 1.0s due to 'Random failure!'... +2025-07-20 09:29:38 [falyx] [WARNING] [step_2] Retry attempt 1/3 failed due to 'Random failure!'. +2025-07-20 09:29:38 [falyx] [INFO] [step_2] Retrying (2/3) in 2.0s due to 'Random failure!'... +2025-07-20 09:29:40 [falyx] [INFO] [step_2] Retry succeeded on attempt 2. +2025-07-20 09:29:40 [falyx] [INFO] [step_2] Recovered: step_2 +2025-07-20 09:29:40 [falyx] [DEBUG] [step_2] status=OK duration=3.609s result='ok' exception=None +2025-07-20 09:29:40 [falyx] [DEBUG] [my_pipeline] Success -> Result: 'ok' +2025-07-20 09:29:40 [falyx] [DEBUG] [my_pipeline] Finished in 7.237s +2025-07-20 09:29:40 [falyx] [DEBUG] [my_pipeline] status=OK duration=7.237s result='ok' exception=None +2025-07-20 09:29:40 [falyx] [DEBUG] [Run My Pipeline] status=OK duration=7.238s result='ok' exception=None ``` ---- +### πŸ“Š History Tracking -### 🧱 Core Building Blocks +View full execution history: -#### `Action` -A single async unit of work. Painless retry support. +```bash +> history + πŸ“Š Execution History -#### `ChainedAction` -Run tasks in sequence. Supports rollback on failure and context propagation. + Index Name Start End Duration Status Result / Exception + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + 0 step_1 09:23:55 09:23:55 0.201s βœ… Success 'ok' + 1 step_2 09:23:55 09:24:03 7.829s ❌ Error RuntimeError('Random failure!') + 2 my_pipeline 09:23:55 09:24:03 8.080s ❌ Error RuntimeError('Random failure!') + 3 Run My Pipeline 09:23:55 09:24:03 8.082s ❌ Error RuntimeError('Random failure!') +``` -#### `ActionGroup` -Run tasks in parallel. Useful for fan-out operations like batch API calls. +Inspect traceback on failure: -#### `ProcessAction` -Offload CPU-bound work to another process β€” no extra code needed. +```bash +> history --result-index 0 +Action(name='step_1', action=flaky_step, args=(), kwargs={}, retry=True, rollback=False) (): +ok +``` -#### `Falyx` -Your CLI controller β€” powers menus, subcommands, history, bottom bars, and more. +Print last result: -#### `ExecutionContext` -Tracks metadata, arguments, timing, and results for each action execution. - -#### `HookManager` -Registers and triggers lifecycle hooks (`before`, `after`, `on_error`, etc.) for actions and commands. +```bash +> history --last-result +Command(key='R', description='Run My Pipeline' action='ChainedAction(name=my_pipeline, actions=['step_1', 'step_2'], +args=(), kwargs={}, auto_inject=False, return_list=False)') (): +Traceback (most recent call last): + File ".../falyx/command.py", line 291, in __call__ + result = await self.action(*combined_args, **combined_kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File ".../falyx/action/base_action.py", line 91, in __call__ + return await self._run(*args, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File ".../falyx/action/chained_action.py", line 212, in _run + result = await prepared(*combined_args, **updated_kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File ".../falyx/action/base_action.py", line 91, in __call__ + return await self._run(*args, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File ".../falyx/action/action.py", line 157, in _run + result = await self.action(*combined_args, **combined_kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File ".../falyx/examples/simple.py", line 15, in flaky_step + raise RuntimeError("Random failure!") +RuntimeError: Random failure! +``` --- @@ -163,6 +222,6 @@ Registers and triggers lifecycle hooks (`before`, `after`, `on_error`, etc.) for > β€œ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**. +Falyx is designed for developers who don’t just want CLI tools to run β€” they want them to **fail meaningfully**, **recover intentionally**, and **log clearly**. --- diff --git a/examples/simple.py b/examples/simple.py index e8f1238..d7f3372 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -11,15 +11,15 @@ setup_logging() # A flaky async step that fails randomly async def flaky_step() -> str: await asyncio.sleep(0.2) - if random.random() < 0.3: + if random.random() < 0.5: raise RuntimeError("Random failure!") - print("Flaky step succeeded!") + print("ok") return "ok" # Create a retry handler -step1 = Action(name="step_1", action=flaky_step, retry=True) -step2 = Action(name="step_2", action=flaky_step, retry=True) +step1 = Action(name="step_1", action=flaky_step) +step2 = Action(name="step_2", action=flaky_step) # Chain the actions chain = ChainedAction(name="my_pipeline", actions=[step1, step2]) @@ -33,6 +33,8 @@ falyx.add_command( logging_hooks=True, preview_before_confirm=True, confirm=True, + retry_all=True, + spinner=True, ) # Entry point diff --git a/falyx/bottom_bar.py b/falyx/bottom_bar.py index 6754a3f..55ca438 100644 --- a/falyx/bottom_bar.py +++ b/falyx/bottom_bar.py @@ -56,6 +56,8 @@ class BottomBar: Must return True if key is available, otherwise False. """ + RESERVED_CTRL_KEYS = {"c", "d", "z"} + def __init__( self, columns: int = 3, @@ -164,13 +166,18 @@ class BottomBar: bg_on (str): Background color when the toggle is ON. bg_off (str): Background color when the toggle is OFF. """ + key = key.lower() + if key in self.RESERVED_CTRL_KEYS: + raise ValueError( + f"'{key}' is a reserved terminal control key and cannot be used for toggles." + ) if not callable(get_state): raise ValueError("`get_state` must be a callable returning bool") if not callable(toggle_state): raise ValueError("`toggle_state` must be a callable") - key = key.lower() if key in self.toggle_keys: raise ValueError(f"Key {key} is already used as a toggle") + self._value_getters[key] = get_state self.toggle_keys.append(key) diff --git a/falyx/execution_registry.py b/falyx/execution_registry.py index ec28da6..4f118f1 100644 --- a/falyx/execution_registry.py +++ b/falyx/execution_registry.py @@ -190,7 +190,11 @@ class ExecutionRegistry: if last_result: for ctx in reversed(cls._store_all): if not ctx.action.ignore_in_history: - cls._console.print(ctx.result) + cls._console.print(f"{ctx.signature}:") + if ctx.traceback: + cls._console.print(ctx.traceback) + else: + cls._console.print(ctx.result) return cls._console.print( f"[{OneColors.DARK_RED}]❌ No valid executions found to display last result." diff --git a/falyx/falyx.py b/falyx/falyx.py index ab5e395..9afc5ad 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -333,9 +333,8 @@ class Falyx: ) parser.add_argument( "-r", - "--result", + "--result-index", type=int, - dest="result_index", help="Get the result by index", ) parser.add_argument( diff --git a/falyx/utils.py b/falyx/utils.py index 19660ef..9766e61 100644 --- a/falyx/utils.py +++ b/falyx/utils.py @@ -30,6 +30,8 @@ from typing import Any, Awaitable, Callable, TypeVar import pythonjsonlogger.json from rich.logging import RichHandler +from falyx.console import console + T = TypeVar("T") @@ -180,6 +182,7 @@ def setup_logging( if mode == "cli": console_handler: RichHandler | logging.StreamHandler = RichHandler( + console=console, rich_tracebacks=True, show_time=True, show_level=True, diff --git a/falyx/version.py b/falyx/version.py index 54c0948..70823f9 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.65" +__version__ = "0.1.66" diff --git a/pyproject.toml b/pyproject.toml index fb9abee..4ccd10b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "falyx" -version = "0.1.65" +version = "0.1.66" description = "Reliable and introspectable async CLI action framework." authors = ["Roland Thomas Jr "] license = "MIT"