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",
|
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 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 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"]:
|
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
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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."""
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue