Add Examples
This commit is contained in:
parent
b9db1cbb36
commit
f14b54743f
22
README.md
22
README.md
|
@ -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 don’t 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**
|
||||
|
||||
---
|
||||
|
|
|
@ -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())
|
|
@ -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())
|
|
@ -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())
|
|
@ -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
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue