Add Examples

This commit is contained in:
Roland Thomas Jr 2025-04-16 21:52:40 -04:00
parent b9db1cbb36
commit f14b54743f
Signed by: roland
GPG Key ID: 7C3C2B085A4C2872
10 changed files with 155 additions and 48 deletions

View File

@ -75,7 +75,6 @@ falyx.add_command(
description="Run My Pipeline",
action=chain,
logging_hooks=True,
# shows preview before confirmation
preview_before_confirm=True,
confirm=True,
)
@ -137,7 +136,7 @@ Confirm execution of R — Run My Pipeline (calls `my_pipeline`) [Y/n] y
### 🧱 Core Building Blocks
#### `Action`
A single async unit of work. Can retry, roll back, or inject prior results.
A single async unit of work. Painless retry support.
#### `ChainedAction`
Run tasks in sequence. Supports rollback on failure and context propagation.
@ -166,22 +165,3 @@ Registers and triggers lifecycle hooks (`before`, `after`, `on_error`, etc.) for
Falyx is designed for developers who dont just want CLI tools to run — they want them to **fail meaningfully**, **recover gracefully**, and **log clearly**.
---
## 🛣️ Roadmap
- [ ] 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**
---

View File

@ -0,0 +1,29 @@
import asyncio
from falyx import Action, ActionGroup, ChainedAction
# Actions can be defined as synchronous functions
# Falyx will automatically convert them to async functions
def hello() -> None:
print("Hello, world!")
hello = Action(name="hello_action", action=hello)
# Actions can be run by themselves or as part of a command or pipeline
asyncio.run(hello())
# Actions are designed to be asynchronous first
async def goodbye() -> None:
print("Goodbye!")
goodbye = Action(name="goodbye_action", action=goodbye)
asyncio.run(goodbye())
# Actions can be run in parallel
group = ActionGroup(name="greeting_group", actions=[hello, goodbye])
asyncio.run(group())
# Actions can be run in a chain
chain = ChainedAction(name="greeting_chain", actions=[hello, goodbye])
asyncio.run(chain())

26
examples/process_pool.py Normal file
View File

@ -0,0 +1,26 @@
from falyx import Falyx, ProcessAction
from falyx.themes.colors import NordColors as nc
from rich.console import Console
console = Console()
falyx = Falyx(title="🚀 Process Pool Demo")
def generate_primes(n):
primes = []
for num in range(2, n):
if all(num % p != 0 for p in primes):
primes.append(num)
console.print(f"Generated {len(primes)} primes up to {n}.", style=nc.GREEN)
return primes
# Will not block the event loop
heavy_action = ProcessAction("Prime Generator", generate_primes, args=(100_000,))
falyx.add_command("R", "Generate Primes", heavy_action, spinner=True)
if __name__ == "__main__":
import asyncio
# Run the menu
asyncio.run(falyx.run())

36
examples/simple.py Normal file
View File

@ -0,0 +1,36 @@
import asyncio
import random
from falyx import Falyx, Action, ChainedAction
from falyx.utils import setup_logging
setup_logging()
# A flaky async step that fails randomly
async def flaky_step():
await asyncio.sleep(0.2)
if random.random() < 0.5:
raise RuntimeError("Random failure!")
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)
# Chain the actions
chain = ChainedAction(name="my_pipeline", actions=[step1, step2])
# Create the CLI menu
falyx = Falyx("🚀 Falyx Demo")
falyx.add_command(
key="R",
description="Run My Pipeline",
action=chain,
logging_hooks=True,
preview_before_confirm=True,
confirm=True,
)
# Entry point
if __name__ == "__main__":
asyncio.run(falyx.run())

View File

@ -362,8 +362,8 @@ class ActionGroup(BaseAction, ActionListMixin):
if context.extra["errors"]:
context.exception = Exception(
f"{len(context.extra['errors'])} action(s) failed: " +
", ".join(name for name, _ in context.extra["errors"])
f"{len(context.extra['errors'])} action(s) failed: "
f"{' ,'.join(name for name, _ in context.extra["errors"])}"
)
await self.hooks.trigger(HookType.ON_ERROR, context)
raise context.exception

View File

@ -1,9 +1,12 @@
"""context.py"""
import time
from datetime import datetime
from typing import Any, Optional
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from rich.console import Console
console = Console(color_system="auto")
class ExecutionContext(BaseModel):
@ -13,23 +16,31 @@ class ExecutionContext(BaseModel):
action: Any
result: Any | None = None
exception: Exception | None = None
start_time: float | None = None
end_time: float | None = None
start_wall: datetime | None = None
end_wall: datetime | None = None
extra: dict[str, Any] = Field(default_factory=dict)
model_config = ConfigDict(arbitrary_types_allowed=True)
def start_timer(self):
self.start_wall = datetime.now()
self.start_time = time.perf_counter()
def stop_timer(self):
self.end_time = time.perf_counter()
self.end_wall = datetime.now()
@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
def duration(self) -> float | None:
if self.start_time is None:
return None
if self.end_time is None:
return time.perf_counter() - self.start_time
return self.end_time - self.start_time
@property
def success(self) -> bool:
@ -50,23 +61,21 @@ class ExecutionContext(BaseModel):
def log_summary(self, logger=None):
summary = self.as_dict()
msg = f"[SUMMARY] {summary['name']} | "
message = [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.start_wall:
message.append(f"Start: {self.start_wall.strftime('%H:%M:%S')} | ")
if self.end_time:
end_str = datetime.fromtimestamp(self.end_time).strftime("%H:%M:%S")
msg += f"End: {end_str} | "
message.append(f"End: {self.end_wall.strftime('%H:%M:%S')} | ")
msg += f"Duration: {summary['duration']:.3f}s | "
message.append(f"Duration: {summary['duration']:.3f}s | ")
if summary["exception"]:
msg += f"❌ Exception: {summary['exception']}"
message.append(f"❌ Exception: {summary['exception']}")
else:
msg += f"✅ Result: {summary['result']}"
(logger or print)(msg)
message.append(f"✅ Result: {summary['result']}")
(logger or console.print)("".join(message))
def to_log_line(self) -> str:
"""Structured flat-line format for logging and metrics."""
@ -125,3 +134,16 @@ class ResultsContext(BaseModel):
f"Results: {self.results} | "
f"Errors: {self.errors}>"
)
if __name__ == "__main__":
import asyncio
async def demo():
ctx = ExecutionContext(name="test", action="demo")
ctx.start_timer()
await asyncio.sleep(0.2)
ctx.stop_timer()
ctx.result = "done"
ctx.log_summary()
asyncio.run(demo())

View File

@ -15,7 +15,7 @@ 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] + "..."
result_str = f"{result_str[:100]} ..."
logger.debug("[%s] ✅ Success → Result: %s", context.name, result_str)
@ -40,4 +40,3 @@ def register_debug_hooks(hooks: HookManager):
hooks.register(HookType.AFTER, log_after)
hooks.register(HookType.ON_SUCCESS, log_success)
hooks.register(HookType.ON_ERROR, log_error)

View File

@ -78,8 +78,8 @@ class Falyx:
prompt: str | AnyFormattedText = "> ",
columns: int = 3,
bottom_bar: BottomBar | str | Callable[[], None] | None = None,
welcome_message: str | Markdown = "",
exit_message: str | Markdown = "",
welcome_message: str | Markdown | dict[str, Any] = "",
exit_message: str | Markdown | dict[str, Any] = "",
key_bindings: KeyBindings | None = None,
include_history_command: bool = True,
include_help_command: bool = False,
@ -98,8 +98,8 @@ class Falyx:
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.welcome_message: str | Markdown | dict[str, Any] = welcome_message
self.exit_message: str | Markdown | dict[str, Any] = exit_message
self.hooks: HookManager = HookManager()
self.last_run_command: Command | None = None
self.key_bindings: KeyBindings = key_bindings or KeyBindings()
@ -755,12 +755,26 @@ class Falyx:
return parser
def print_message(self, message: str | Markdown | dict[str, Any]) -> None:
"""Prints a message to the console."""
if isinstance(message, (str, Markdown)):
self.console.print(message)
elif isinstance(message, dict):
self.console.print(
*message.get("args", tuple()),
**message.get("kwargs", {}),
)
else:
raise TypeError(
"Message must be a string, Markdown, or dictionary with args and kwargs."
)
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)
self.print_message(self.welcome_message)
while True:
self.console.print(self.table)
try:
@ -773,7 +787,7 @@ class Falyx:
break
logger.info(f"Exiting menu: {self.get_title()}")
if self.exit_message:
self.console.print(self.exit_message)
self.print_message(self.exit_message)
async def run(self, parser: ArgumentParser | None = None) -> None:
"""Run Falyx CLI with structured subcommands."""

View File

@ -89,7 +89,7 @@ class ColorsMeta(type):
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))
error_msg.append(f"Valid base color names include: {', '.join(valid_bases)}")
raise AttributeError(" ".join(error_msg)) from None
if not isinstance(color_value, str):

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "falyx"
version = "0.1.0"
version = "0.1.2"
description = "Reliable and introspectable async CLI action framework."
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
license = "MIT"
@ -17,6 +17,7 @@ pydantic = "^2.0"
pytest = "^7.0"
pytest-asyncio = "^0.20"
ruff = "^0.3"
python-json-logger = "^3.3.0"
[tool.poetry.scripts]
falyx = "falyx.cli.main:main"