diff --git a/README.md b/README.md index 53352e4..fae8266 100644 --- a/README.md +++ b/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** - ---- diff --git a/examples/action_example.py b/examples/action_example.py new file mode 100644 index 0000000..cd4666b --- /dev/null +++ b/examples/action_example.py @@ -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()) diff --git a/examples/process_pool.py b/examples/process_pool.py new file mode 100644 index 0000000..c42b29d --- /dev/null +++ b/examples/process_pool.py @@ -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()) diff --git a/examples/simple.py b/examples/simple.py new file mode 100644 index 0000000..3b7c754 --- /dev/null +++ b/examples/simple.py @@ -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()) diff --git a/falyx/action.py b/falyx/action.py index 57df0e6..fc31520 100644 --- a/falyx/action.py +++ b/falyx/action.py @@ -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 diff --git a/falyx/context.py b/falyx/context.py index 453c66b..16e3f07 100644 --- a/falyx/context.py +++ b/falyx/context.py @@ -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 - return None + 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()) diff --git a/falyx/debug.py b/falyx/debug.py index 53ab195..0c51887 100644 --- a/falyx/debug.py +++ b/falyx/debug.py @@ -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) - diff --git a/falyx/falyx.py b/falyx/falyx.py index c9c8036..6951c1e 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -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.""" diff --git a/falyx/themes/colors.py b/falyx/themes/colors.py index 480499d..acaf26c 100644 --- a/falyx/themes/colors.py +++ b/falyx/themes/colors.py @@ -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): diff --git a/pyproject.toml b/pyproject.toml index c2f5e57..d56e4c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] 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"