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", description="Run My Pipeline",
action=chain, action=chain,
logging_hooks=True, logging_hooks=True,
# shows preview before confirmation
preview_before_confirm=True, preview_before_confirm=True,
confirm=True, confirm=True,
) )
@ -137,7 +136,7 @@ Confirm execution of R — Run My Pipeline (calls `my_pipeline`) [Y/n] y
### 🧱 Core Building Blocks ### 🧱 Core Building Blocks
#### `Action` #### `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` #### `ChainedAction`
Run tasks in sequence. Supports rollback on failure and context propagation. 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**. 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"]: if context.extra["errors"]:
context.exception = Exception( context.exception = Exception(
f"{len(context.extra['errors'])} action(s) failed: " + f"{len(context.extra['errors'])} action(s) failed: "
", ".join(name for name, _ in context.extra["errors"]) f"{' ,'.join(name for name, _ in context.extra["errors"])}"
) )
await self.hooks.trigger(HookType.ON_ERROR, context) await self.hooks.trigger(HookType.ON_ERROR, context)
raise context.exception raise context.exception

View File

@ -1,9 +1,12 @@
"""context.py""" """context.py"""
import time import time
from datetime import datetime from datetime import datetime
from typing import Any, Optional from typing import Any
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from rich.console import Console
console = Console(color_system="auto")
class ExecutionContext(BaseModel): class ExecutionContext(BaseModel):
@ -13,23 +16,31 @@ class ExecutionContext(BaseModel):
action: Any action: Any
result: Any | None = None result: Any | None = None
exception: Exception | None = None exception: Exception | None = None
start_time: float | None = None start_time: float | None = None
end_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) extra: dict[str, Any] = Field(default_factory=dict)
model_config = ConfigDict(arbitrary_types_allowed=True) model_config = ConfigDict(arbitrary_types_allowed=True)
def start_timer(self): def start_timer(self):
self.start_wall = datetime.now()
self.start_time = time.perf_counter() self.start_time = time.perf_counter()
def stop_timer(self): def stop_timer(self):
self.end_time = time.perf_counter() self.end_time = time.perf_counter()
self.end_wall = datetime.now()
@property @property
def duration(self) -> Optional[float]: def duration(self) -> float | None:
if self.start_time is not None and self.end_time is not None: if self.start_time is None:
return self.end_time - self.start_time return None
return None if self.end_time is None:
return time.perf_counter() - self.start_time
return self.end_time - self.start_time
@property @property
def success(self) -> bool: def success(self) -> bool:
@ -50,23 +61,21 @@ class ExecutionContext(BaseModel):
def log_summary(self, logger=None): def log_summary(self, logger=None):
summary = self.as_dict() summary = self.as_dict()
msg = f"[SUMMARY] {summary['name']} | " message = [f"[SUMMARY] {summary['name']} | "]
if self.start_time: if self.start_wall:
start_str = datetime.fromtimestamp(self.start_time).strftime("%H:%M:%S") message.append(f"Start: {self.start_wall.strftime('%H:%M:%S')} | ")
msg += f"Start: {start_str} | "
if self.end_time: if self.end_time:
end_str = datetime.fromtimestamp(self.end_time).strftime("%H:%M:%S") message.append(f"End: {self.end_wall.strftime('%H:%M:%S')} | ")
msg += f"End: {end_str} | "
msg += f"Duration: {summary['duration']:.3f}s | " message.append(f"Duration: {summary['duration']:.3f}s | ")
if summary["exception"]: if summary["exception"]:
msg += f"❌ Exception: {summary['exception']}" message.append(f"❌ Exception: {summary['exception']}")
else: else:
msg += f"✅ Result: {summary['result']}" message.append(f"✅ Result: {summary['result']}")
(logger or print)(msg) (logger or console.print)("".join(message))
def to_log_line(self) -> str: def to_log_line(self) -> str:
"""Structured flat-line format for logging and metrics.""" """Structured flat-line format for logging and metrics."""
@ -125,3 +134,16 @@ class ResultsContext(BaseModel):
f"Results: {self.results} | " f"Results: {self.results} | "
f"Errors: {self.errors}>" 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.""" """Log the successful completion of an action."""
result_str = repr(context.result) result_str = repr(context.result)
if len(result_str) > 100: 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) 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.AFTER, log_after)
hooks.register(HookType.ON_SUCCESS, log_success) hooks.register(HookType.ON_SUCCESS, log_success)
hooks.register(HookType.ON_ERROR, log_error) hooks.register(HookType.ON_ERROR, log_error)

View File

@ -78,8 +78,8 @@ class Falyx:
prompt: str | AnyFormattedText = "> ", prompt: str | AnyFormattedText = "> ",
columns: int = 3, columns: int = 3,
bottom_bar: BottomBar | str | Callable[[], None] | None = None, bottom_bar: BottomBar | str | Callable[[], None] | None = None,
welcome_message: str | Markdown = "", welcome_message: str | Markdown | dict[str, Any] = "",
exit_message: str | Markdown = "", exit_message: str | Markdown | dict[str, Any] = "",
key_bindings: KeyBindings | None = None, key_bindings: KeyBindings | None = None,
include_history_command: bool = True, include_history_command: bool = True,
include_help_command: bool = False, 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.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.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.console: Console = Console(color_system="truecolor", theme=get_nord_theme())
self.welcome_message: str | Markdown = welcome_message self.welcome_message: str | Markdown | dict[str, Any] = welcome_message
self.exit_message: str | Markdown = exit_message self.exit_message: str | Markdown | dict[str, Any] = exit_message
self.hooks: HookManager = HookManager() self.hooks: HookManager = HookManager()
self.last_run_command: Command | None = None self.last_run_command: Command | None = None
self.key_bindings: KeyBindings = key_bindings or KeyBindings() self.key_bindings: KeyBindings = key_bindings or KeyBindings()
@ -755,12 +755,26 @@ class Falyx:
return parser 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: async def menu(self) -> None:
"""Runs the menu and handles user input.""" """Runs the menu and handles user input."""
logger.info(f"Running menu: {self.get_title()}") logger.info(f"Running menu: {self.get_title()}")
self.debug_hooks() self.debug_hooks()
if self.welcome_message: if self.welcome_message:
self.console.print(self.welcome_message) self.print_message(self.welcome_message)
while True: while True:
self.console.print(self.table) self.console.print(self.table)
try: try:
@ -773,7 +787,7 @@ class Falyx:
break break
logger.info(f"Exiting menu: {self.get_title()}") logger.info(f"Exiting menu: {self.get_title()}")
if self.exit_message: 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: async def run(self, parser: ArgumentParser | None = None) -> None:
"""Run Falyx CLI with structured subcommands.""" """Run Falyx CLI with structured subcommands."""

View File

@ -89,7 +89,7 @@ class ColorsMeta(type):
if suggestions: if suggestions:
error_msg.append(f"Did you mean '{suggestions[0]}'?") error_msg.append(f"Did you mean '{suggestions[0]}'?")
if valid_bases: 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 raise AttributeError(" ".join(error_msg)) from None
if not isinstance(color_value, str): if not isinstance(color_value, str):

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "falyx" name = "falyx"
version = "0.1.0" version = "0.1.2"
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"
@ -17,6 +17,7 @@ pydantic = "^2.0"
pytest = "^7.0" pytest = "^7.0"
pytest-asyncio = "^0.20" pytest-asyncio = "^0.20"
ruff = "^0.3" ruff = "^0.3"
python-json-logger = "^3.3.0"
[tool.poetry.scripts] [tool.poetry.scripts]
falyx = "falyx.cli.main:main" falyx = "falyx.cli.main:main"