Add reserved ctrl keys to BottomBar, Add traceback support in History for --last-result
This commit is contained in:
149
README.md
149
README.md
@ -10,7 +10,7 @@
|
|||||||
- ⚙️ Full lifecycle hooks (before, after, success, error, teardown)
|
- ⚙️ Full lifecycle hooks (before, after, success, error, teardown)
|
||||||
- 📊 Execution tracing, logging, and introspection
|
- 📊 Execution tracing, logging, and introspection
|
||||||
- 🧙♂️ Async-first design with Process support
|
- 🧙♂️ 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.
|
> 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:
|
Modern CLI tools deserve the same resilience as production systems. Falyx makes it easy to:
|
||||||
|
|
||||||
- Compose workflows using `Action`, `ChainedAction`, or `ActionGroup`
|
- Compose workflows using `Action`, `ChainedAction`, or `ActionGroup`
|
||||||
- Inject the result of one step into the next (`last_result`)
|
- Inject the result of one step into the next (`last_result` / `auto_inject`)
|
||||||
- Handle flaky operations with retries and exponential backoff
|
- Handle flaky operations with retries, backoff, and jitter
|
||||||
- Roll back safely on failure with structured undo logic
|
- 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
|
- 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)
|
await asyncio.sleep(0.2)
|
||||||
if random.random() < 0.5:
|
if random.random() < 0.5:
|
||||||
raise RuntimeError("Random failure!")
|
raise RuntimeError("Random failure!")
|
||||||
|
print("ok")
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
# Create the actions
|
# Create the actions
|
||||||
@ -78,6 +80,8 @@ falyx.add_command(
|
|||||||
preview_before_confirm=True,
|
preview_before_confirm=True,
|
||||||
confirm=True,
|
confirm=True,
|
||||||
retry_all=True,
|
retry_all=True,
|
||||||
|
spinner=True,
|
||||||
|
style="cyan",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Entry point
|
# Entry point
|
||||||
@ -86,76 +90,131 @@ if __name__ == "__main__":
|
|||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
❯ python simple.py
|
$ python simple.py
|
||||||
🚀 Falyx Demo
|
🚀 Falyx Demo
|
||||||
|
|
||||||
[R] Run My Pipeline
|
[R] Run My Pipeline
|
||||||
[Y] History [Q] Exit
|
[H] Help [Y] History [X] Exit
|
||||||
|
|
||||||
>
|
>
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
❯ python simple.py run R
|
$ python simple.py run r
|
||||||
Command: 'R' — Run My Pipeline
|
Command: 'R' — Run My Pipeline
|
||||||
└── ⛓ ChainedAction 'my_pipeline'
|
└── ⛓ ChainedAction 'my_pipeline'
|
||||||
├── ⚙ Action 'step_1'
|
├── ⚙ Action 'step_1'
|
||||||
│ ↻ Retries: 3x, delay 1.0s, backoff 2.0x
|
│ ↻ Retries: 3x, delay 1.0s, backoff 2.0x
|
||||||
└── ⚙ Action 'step_2'
|
└── ⚙ Action 'step_2'
|
||||||
↻ Retries: 3x, delay 1.0s, backoff 2.0x
|
↻ Retries: 3x, delay 1.0s, backoff 2.0x
|
||||||
Confirm execution of R — Run My Pipeline (calls `my_pipeline`) [Y/n] y
|
❓ 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!'.
|
[2025-07-20 09:29:35] WARNING Retry attempt 1/3 failed due to 'Random failure!'.
|
||||||
✅ Result: ['ok', 'ok']
|
ok
|
||||||
|
[2025-07-20 09:29:38] WARNING Retry attempt 1/3 failed due to 'Random failure!'.
|
||||||
|
ok
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📦 Core Features
|
## 📦 Core Features
|
||||||
|
|
||||||
- ✅ Async-native `Action`, `ChainedAction`, `ActionGroup`
|
- ✅ Async-native `Action`, `ChainedAction`, `ActionGroup`, `ProcessAction`
|
||||||
- 🔁 Retry policies + exponential backoff
|
- 🔁 Retry policies with delay, backoff, jitter — opt-in per action or globally
|
||||||
- ⛓ Rollbacks on chained failures
|
- ⛓ Rollbacks and lifecycle hooks for chained execution
|
||||||
- 🎛️ Headless or interactive CLI with argparse and prompt_toolkit
|
- 🎛️ Headless or interactive CLI powered by `argparse` + `prompt_toolkit`
|
||||||
- 📊 Built-in execution registry, result tracking, and timing
|
- 📊 In-memory `ExecutionRegistry` with result tracking, timing, and tracebacks
|
||||||
- 🧠 Supports `ProcessAction` for CPU-bound workloads
|
- 🌐 CLI menu construction via config files or Python
|
||||||
- 🧩 Custom `Table` rendering for CLI menu views
|
- ⚡ Bottom bar toggle switches and counters with `Ctrl+<key>` shortcuts
|
||||||
- 🔍 Hook lifecycle: `before`, `on_success`, `on_error`, `after`, `on_teardown`
|
- 🔍 Structured confirmation prompts and help rendering
|
||||||
|
- 🪵 Flexible logging: Rich console for devs, JSON logs for ops
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔍 Execution Trace
|
### 🧰 Building Blocks
|
||||||
|
|
||||||
```bash
|
- **`Action`**: A single unit of async (or sync) logic
|
||||||
[2025-04-14 10:33:22] DEBUG [Step 1] ⚙ flaky_step()
|
- **`ChainedAction`**: Execute a sequence of actions, with rollback and injection
|
||||||
[2025-04-14 10:33:22] INFO [Step 1] 🔁 Retrying (1/3) in 1.0s...
|
- **`ActionGroup`**: Run actions concurrently and collect results
|
||||||
[2025-04-14 10:33:23] DEBUG [Step 1] ✅ Success | Result: ok
|
- **`ProcessAction`**: Use `multiprocessing` for CPU-bound workflows
|
||||||
[2025-04-14 10:33:23] DEBUG [My Pipeline] ✅ Result: ['ok', 'ok']
|
- **`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`
|
```bash
|
||||||
A single async unit of work. Painless retry support.
|
> history
|
||||||
|
📊 Execution History
|
||||||
|
|
||||||
#### `ChainedAction`
|
Index Name Start End Duration Status Result / Exception
|
||||||
Run tasks in sequence. Supports rollback on failure and context propagation.
|
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
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`
|
Inspect traceback on failure:
|
||||||
Run tasks in parallel. Useful for fan-out operations like batch API calls.
|
|
||||||
|
|
||||||
#### `ProcessAction`
|
```bash
|
||||||
Offload CPU-bound work to another process — no extra code needed.
|
> history --result-index 0
|
||||||
|
Action(name='step_1', action=flaky_step, args=(), kwargs={}, retry=True, rollback=False) ():
|
||||||
|
ok
|
||||||
|
```
|
||||||
|
|
||||||
#### `Falyx`
|
Print last result:
|
||||||
Your CLI controller — powers menus, subcommands, history, bottom bars, and more.
|
|
||||||
|
|
||||||
#### `ExecutionContext`
|
```bash
|
||||||
Tracks metadata, arguments, timing, and results for each action execution.
|
> history --last-result
|
||||||
|
Command(key='R', description='Run My Pipeline' action='ChainedAction(name=my_pipeline, actions=['step_1', 'step_2'],
|
||||||
#### `HookManager`
|
args=(), kwargs={}, auto_inject=False, return_list=False)') ():
|
||||||
Registers and triggers lifecycle hooks (`before`, `after`, `on_error`, etc.) for actions and commands.
|
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.”
|
> “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**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
@ -11,15 +11,15 @@ setup_logging()
|
|||||||
# A flaky async step that fails randomly
|
# A flaky async step that fails randomly
|
||||||
async def flaky_step() -> str:
|
async def flaky_step() -> str:
|
||||||
await asyncio.sleep(0.2)
|
await asyncio.sleep(0.2)
|
||||||
if random.random() < 0.3:
|
if random.random() < 0.5:
|
||||||
raise RuntimeError("Random failure!")
|
raise RuntimeError("Random failure!")
|
||||||
print("Flaky step succeeded!")
|
print("ok")
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
# Create a retry handler
|
# Create a retry handler
|
||||||
step1 = Action(name="step_1", action=flaky_step, retry=True)
|
step1 = Action(name="step_1", action=flaky_step)
|
||||||
step2 = Action(name="step_2", action=flaky_step, retry=True)
|
step2 = Action(name="step_2", action=flaky_step)
|
||||||
|
|
||||||
# Chain the actions
|
# Chain the actions
|
||||||
chain = ChainedAction(name="my_pipeline", actions=[step1, step2])
|
chain = ChainedAction(name="my_pipeline", actions=[step1, step2])
|
||||||
@ -33,6 +33,8 @@ falyx.add_command(
|
|||||||
logging_hooks=True,
|
logging_hooks=True,
|
||||||
preview_before_confirm=True,
|
preview_before_confirm=True,
|
||||||
confirm=True,
|
confirm=True,
|
||||||
|
retry_all=True,
|
||||||
|
spinner=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Entry point
|
# Entry point
|
||||||
|
@ -56,6 +56,8 @@ class BottomBar:
|
|||||||
Must return True if key is available, otherwise False.
|
Must return True if key is available, otherwise False.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
RESERVED_CTRL_KEYS = {"c", "d", "z"}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
columns: int = 3,
|
columns: int = 3,
|
||||||
@ -164,13 +166,18 @@ class BottomBar:
|
|||||||
bg_on (str): Background color when the toggle is ON.
|
bg_on (str): Background color when the toggle is ON.
|
||||||
bg_off (str): Background color when the toggle is OFF.
|
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):
|
if not callable(get_state):
|
||||||
raise ValueError("`get_state` must be a callable returning bool")
|
raise ValueError("`get_state` must be a callable returning bool")
|
||||||
if not callable(toggle_state):
|
if not callable(toggle_state):
|
||||||
raise ValueError("`toggle_state` must be a callable")
|
raise ValueError("`toggle_state` must be a callable")
|
||||||
key = key.lower()
|
|
||||||
if key in self.toggle_keys:
|
if key in self.toggle_keys:
|
||||||
raise ValueError(f"Key {key} is already used as a toggle")
|
raise ValueError(f"Key {key} is already used as a toggle")
|
||||||
|
|
||||||
self._value_getters[key] = get_state
|
self._value_getters[key] = get_state
|
||||||
self.toggle_keys.append(key)
|
self.toggle_keys.append(key)
|
||||||
|
|
||||||
|
@ -190,7 +190,11 @@ class ExecutionRegistry:
|
|||||||
if last_result:
|
if last_result:
|
||||||
for ctx in reversed(cls._store_all):
|
for ctx in reversed(cls._store_all):
|
||||||
if not ctx.action.ignore_in_history:
|
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
|
return
|
||||||
cls._console.print(
|
cls._console.print(
|
||||||
f"[{OneColors.DARK_RED}]❌ No valid executions found to display last result."
|
f"[{OneColors.DARK_RED}]❌ No valid executions found to display last result."
|
||||||
|
@ -333,9 +333,8 @@ class Falyx:
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-r",
|
"-r",
|
||||||
"--result",
|
"--result-index",
|
||||||
type=int,
|
type=int,
|
||||||
dest="result_index",
|
|
||||||
help="Get the result by index",
|
help="Get the result by index",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
@ -30,6 +30,8 @@ from typing import Any, Awaitable, Callable, TypeVar
|
|||||||
import pythonjsonlogger.json
|
import pythonjsonlogger.json
|
||||||
from rich.logging import RichHandler
|
from rich.logging import RichHandler
|
||||||
|
|
||||||
|
from falyx.console import console
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
@ -180,6 +182,7 @@ def setup_logging(
|
|||||||
|
|
||||||
if mode == "cli":
|
if mode == "cli":
|
||||||
console_handler: RichHandler | logging.StreamHandler = RichHandler(
|
console_handler: RichHandler | logging.StreamHandler = RichHandler(
|
||||||
|
console=console,
|
||||||
rich_tracebacks=True,
|
rich_tracebacks=True,
|
||||||
show_time=True,
|
show_time=True,
|
||||||
show_level=True,
|
show_level=True,
|
||||||
|
@ -1 +1 @@
|
|||||||
__version__ = "0.1.65"
|
__version__ = "0.1.66"
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "falyx"
|
name = "falyx"
|
||||||
version = "0.1.65"
|
version = "0.1.66"
|
||||||
description = "Reliable and introspectable async CLI action framework."
|
description = "Reliable and introspectable async CLI action framework."
|
||||||
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
|
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
Reference in New Issue
Block a user