diff --git a/examples/pipeline_demo.py b/examples/pipeline_demo.py index 83cd671..59df6e8 100644 --- a/examples/pipeline_demo.py +++ b/examples/pipeline_demo.py @@ -4,44 +4,44 @@ import time from falyx import Falyx from falyx.action import Action, ActionGroup, ChainedAction, ProcessAction -from falyx.retry import RetryHandler, RetryPolicy +from falyx.console import console # Step 1: Fast I/O-bound setup (standard Action) async def checkout_code(): - print("📥 Checking out code...") + console.print("🔄 Checking out code...") await asyncio.sleep(0.5) + console.print("📦 Code checked out successfully.") # Step 2: CPU-bound task (ProcessAction) def run_static_analysis(): - print("🧠 Running static analysis (CPU-bound)...") total = 0 for i in range(10_000_000): total += i % 3 - time.sleep(5) + time.sleep(2) return total # Step 3: Simulated flaky test with retry async def flaky_tests(): + console.print("🧪 Running tests...") await asyncio.sleep(0.3) if random.random() < 0.3: raise RuntimeError("❌ Random test failure!") - print("🧪 Tests passed.") + console.print("🧪 Tests passed.") return "ok" # Step 4: Multiple deploy targets (parallel ActionGroup) async def deploy_to(target: str): - print(f"🚀 Deploying to {target}...") + console.print(f"🚀 Deploying to {target}...") await asyncio.sleep(random.randint(2, 6)) + console.print(f"✅ Deployment to {target} complete.") return f"{target} complete" def build_pipeline(): - retry_handler = RetryHandler(RetryPolicy(max_retries=3, delay=0.5)) - # Base actions checkout = Action("Checkout", checkout_code) analysis = ProcessAction( @@ -50,8 +50,17 @@ def build_pipeline(): spinner=True, spinner_message="Analyzing code...", ) - tests = Action("Run Tests", flaky_tests) - tests.hooks.register("on_error", retry_handler.retry_on_error) + analysis.hooks.register( + "before", lambda ctx: console.print("🧠 Running static analysis (CPU-bound)...") + ) + analysis.hooks.register("after", lambda ctx: console.print("🧠 Analysis complete!")) + tests = Action( + "Run Tests", + flaky_tests, + retry=True, + spinner=True, + spinner_message="Running tests...", + ) # Parallel deploys deploy_group = ActionGroup( @@ -91,14 +100,18 @@ pipeline = build_pipeline() # Run the pipeline async def main(): - flx = Falyx() + flx = Falyx( + hide_menu_table=True, program="pipeline_demo.py", show_placeholder_menu=True + ) flx.add_command( - "A", - "Action Thing", + "P", + "Run Pipeline", pipeline, spinner=True, spinner_type="line", spinner_message="Running pipeline...", + tags=["pipeline", "demo"], + help_text="Run the full CI/CD pipeline demo.", ) await flx.run() diff --git a/falyx/command.py b/falyx/command.py index 8833cd1..a7b1cf1 100644 --- a/falyx/command.py +++ b/falyx/command.py @@ -22,8 +22,6 @@ from typing import Any, Awaitable, Callable from prompt_toolkit.formatted_text import FormattedText from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator -from rich.padding import Padding -from rich.panel import Panel from rich.tree import Tree from falyx.action.action import Action @@ -342,7 +340,7 @@ class Command(BaseModel): return f" {command_keys_text:<20} {options_text} " @property - def help_signature(self) -> tuple[Padding, str]: + def help_signature(self) -> tuple[str, str, str]: """Generate a help signature for the command.""" is_cli_mode = self.options_manager.get("mode") in { FalyxMode.RUN, @@ -353,30 +351,21 @@ class Command(BaseModel): program = f"{self.program} run " if is_cli_mode else "" if self.arg_parser and not self.simple_help_signature: - usage = Padding( - Panel( - f"[{self.style}]{program}[/]{self.arg_parser.get_usage()}", - expand=False, - ), - (0, 2), - ) - description = [f" [dim]{self.help_text or self.description}[/dim]"] + usage = f"[{self.style}]{program}[/]{self.arg_parser.get_usage()}" + description = f"[dim]{self.help_text or self.description}[/dim]" if self.tags: - description.append(f" [dim]Tags: {', '.join(self.tags)}[/dim]") - return usage, "\n".join(description) + tags = f"[dim]Tags: {', '.join(self.tags)}[/dim]" + else: + tags = "" + return usage, description, tags command_keys = " | ".join( [f"[{self.style}]{self.key}[/{self.style}]"] + [f"[{self.style}]{alias}[/{self.style}]" for alias in self.aliases] ) return ( - Padding( - Panel( - f"[{self.style}]{program}[/]{command_keys} {self.description}", - expand=False, - ), - (0, 2), - ), + f"[{self.style}]{program}[/]{command_keys}", + f"[dim]{self.description}[/dim]", "", ) @@ -384,7 +373,7 @@ class Command(BaseModel): if self._context: self._context.log_summary() - def show_help(self) -> bool: + def render_help(self) -> bool: """Display the help message for the command.""" if callable(self.custom_help): output = self.custom_help() diff --git a/falyx/falyx.py b/falyx/falyx.py index 8d4502a..c483ea3 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -41,6 +41,8 @@ from prompt_toolkit.validation import ValidationError from rich import box from rich.console import Console from rich.markdown import Markdown +from rich.padding import Padding +from rich.panel import Panel from rich.table import Table from falyx.action.action import Action @@ -347,71 +349,97 @@ class Falyx: def get_tip(self) -> str: program = f"{self.program} run " if self.is_cli_mode else "" tips = [ - f"Tip: Use '{program}?[COMMAND]' to preview a command.", - "Tip: Every command supports aliases—try abbreviating the name!", - f"Tip: Use '{program}H' to reopen this help menu anytime.", - f"Tip: '{program}[COMMAND] --help' prints a detailed help message.", - "Tip: [bold]CLI[/] and [bold]Menu[/] mode—commands run the same way in both.", - f"Tip: Use '{self.program} --never-prompt' to disable all prompts for the [bold italic]entire menu session[/].", - f"Tip: Use '{self.program} --verbose' to enable debug logging for a menu session.", - f"Tip: '{self.program} --debug-hooks' will trace every before/after hook in action.", - f"Tip: Run commands directly from the CLI: '{self.program} run [COMMAND] [OPTIONS]'.", + f"Use '{program}?[COMMAND]' to preview a command.", + "Every command supports aliases—try abbreviating the name!", + f"Use '{program}H' to reopen this help menu anytime.", + f"'{program}[COMMAND] --help' prints a detailed help message.", + "[bold]CLI[/] and [bold]Menu[/] mode—commands run the same way in both.", + f"'{self.program} --never-prompt' to disable all prompts for the [bold italic]entire menu session[/].", + f"Use '{self.program} --verbose' to enable debug logging for a menu session.", + f"'{self.program} --debug-hooks' will trace every before/after hook in action.", + f"Run commands directly from the CLI: '{self.program} run [COMMAND] [OPTIONS]'.", ] if self.is_cli_mode: tips.extend( [ - f"Tip: Use '{self.program} run ?' to list all commands at any time.", - f"Tip: Use '{self.program} --never-prompt run [COMMAND] [OPTIONS]' to disable all prompts for [bold italic]just this command[/].", - f"Tip: Use '{self.program} run --skip-confirm [COMMAND] [OPTIONS]' to skip confirmations.", - f"Tip: Use '{self.program} run --summary [COMMAND] [OPTIONS]' to print a post-run summary.", - f"Tip: Use '{self.program} --verbose run [COMMAND] [OPTIONS]' to enable debug logging for any run.", - "Tip: Use '--skip-confirm' for automation scripts where no prompts are wanted.", + f"Use '{self.program} run ?' to list all commands at any time.", + f"Use '{self.program} --never-prompt run [COMMAND] [OPTIONS]' to disable all prompts for [bold italic]just this command[/].", + f"Use '{self.program} run --skip-confirm [COMMAND] [OPTIONS]' to skip confirmations.", + f"Use '{self.program} run --summary [COMMAND] [OPTIONS]' to print a post-run summary.", + f"Use '{self.program} --verbose run [COMMAND] [OPTIONS]' to enable debug logging for any run.", + "Use '--skip-confirm' for automation scripts where no prompts are wanted.", ] ) else: tips.extend( [ - "Tip: Use '[?]' alone to list all commands at any time.", - "Tip: '[CTRL+KEY]' toggles are available in menu mode for quick switches.", - "Tip: '[Y]' opens the command history viewer.", - "Tip: Use '[X]' in menu mode to exit.", + "Use '[?]' alone to list all commands at any time.", + "'[CTRL+KEY]' toggles are available in menu mode for quick switches.", + "'[Y]' opens the command history viewer.", + "Use '[X]' in menu mode to exit.", ] ) return choice(tips) - async def _show_help(self, tag: str = "") -> None: - self.console.print("[bold]help:[/bold]") + async def _render_help(self, tag: str = "") -> None: if tag: tag_lower = tag.lower() + self.console.print(f"[bold]{tag_lower}:[/bold]") commands = [ command for command in self.commands.values() if any(tag_lower == tag.lower() for tag in command.tags) ] + if not commands: + self.console.print(f"'{tag}'... Nothing to show here") + return for command in commands: - usage, description = command.help_signature + usage, description, _ = command.help_signature self.console.print(usage) if description: self.console.print(description) return + self.console.print("[bold]help:[/bold]") for command in self.commands.values(): - usage, description = command.help_signature - self.console.print(usage) - if description: - self.console.print(description) + usage, description, tag = command.help_signature + self.console.print( + Padding( + Panel( + usage, + expand=False, + title=description, + title_align="left", + subtitle=tag, + ), + (0, 2), + ) + ) if self.help_command: - usage, description = self.help_command.help_signature - self.console.print(usage) - self.console.print(description) + usage, description, _ = self.help_command.help_signature + self.console.print( + Padding( + Panel(usage, expand=False, title=description, title_align="left"), + (0, 2), + ) + ) if not self.is_cli_mode: if self.history_command: - usage, description = self.history_command.help_signature - self.console.print(usage) - self.console.print(description) - usage, _ = self.exit_command.help_signature - self.console.print(usage) - self.console.print(self.get_tip()) + usage, description, _ = self.history_command.help_signature + self.console.print( + Padding( + Panel(usage, expand=False, title=description, title_align="left"), + (0, 2), + ) + ) + usage, description, _ = self.exit_command.help_signature + self.console.print( + Padding( + Panel(usage, expand=False, title=description, title_align="left"), + (0, 2), + ) + ) + self.console.print(f"[bold]tip:[/bold] {self.get_tip()}") def _get_help_command(self) -> Command: """Returns the help command for the menu.""" @@ -434,7 +462,7 @@ class Falyx: aliases=["?", "HELP", "LIST"], description="Help", help_text="Show this help menu", - action=Action("Help", self._show_help), + action=Action("Help", self._render_help), style=OneColors.LIGHT_YELLOW, arg_parser=parser, ignore_in_history=True, @@ -884,7 +912,7 @@ class Falyx: args, kwargs = await run_command.parse_args(input_args, from_validate) except (CommandArgumentError, Exception) as error: if not from_validate: - run_command.show_help() + run_command.render_help() self.console.print( f"[{OneColors.DARK_RED}]❌ [{run_command.key}]: {error}" ) @@ -955,7 +983,7 @@ class Falyx: async def process_command(self) -> bool: """Processes the action of the selected command.""" app = get_app() - await asyncio.sleep(0.01) + await asyncio.sleep(0.1) app.invalidate() with patch_stdout(raw=True): choice = await self.prompt_session.prompt_async() @@ -1171,7 +1199,7 @@ class Falyx: self.register_all_with_debug_hooks() if self.cli_args.command == "list": - await self._show_help(tag=self.cli_args.tag) + await self._render_help(tag=self.cli_args.tag) sys.exit(0) if self.cli_args.command == "version" or self.cli_args.version: @@ -1210,7 +1238,7 @@ class Falyx: sys.exit(0) except CommandArgumentError as error: self.console.print(f"[{OneColors.DARK_RED}]❌ ['{command.key}'] {error}") - command.show_help() + command.render_help() sys.exit(1) try: await self.run_key(self.cli_args.name, args=args, kwargs=kwargs) diff --git a/falyx/hooks.py b/falyx/hooks.py index dd97538..c26767d 100644 --- a/falyx/hooks.py +++ b/falyx/hooks.py @@ -3,6 +3,8 @@ Defines reusable lifecycle hooks for Falyx Actions and Commands. This module includes: +- `spinner_before_hook`: Automatically starts a spinner before an action runs. +- `spinner_teardown_hook`: Stops and clears the spinner after the action completes. - `ResultReporter`: A success hook that displays a formatted result with duration. - `CircuitBreaker`: A failure-aware hook manager that prevents repeated execution after a configurable number of failures. @@ -11,6 +13,7 @@ These hooks can be registered on `HookManager` instances via lifecycle stages (`before`, `on_error`, `after`, etc.) to enhance resiliency and observability. Intended for use with: +- Actions that require user feedback during long-running operations. - Retryable or unstable actions - Interactive CLI feedback - Safety checks prior to execution @@ -62,7 +65,7 @@ async def spinner_teardown_hook(context: ExecutionContext): else: cmd_name = cmd.key sm = context.action.options_manager.spinners - sm.remove(cmd_name) + await sm.remove(cmd_name) class ResultReporter: diff --git a/falyx/menu.py b/falyx/menu.py index cb6a0bd..6684866 100644 --- a/falyx/menu.py +++ b/falyx/menu.py @@ -1,3 +1,4 @@ +# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed """ Defines `MenuOption` and `MenuOptionMap`, core components used to construct interactive menus within Falyx Actions such as `MenuAction` and `PromptMenuAction`. diff --git a/falyx/parser/command_argument_parser.py b/falyx/parser/command_argument_parser.py index 4a5eaa7..a1c0d71 100644 --- a/falyx/parser/command_argument_parser.py +++ b/falyx/parser/command_argument_parser.py @@ -24,6 +24,7 @@ Public Interface: - `parse_args(...)`: Parse CLI-style argument list into a `dict[str, Any]`. - `parse_args_split(...)`: Return `(*args, **kwargs)` for Action invocation. - `render_help()`: Render a rich-styled help panel. +- `render_tldr()`: Render quick usage examples. - `suggest_next(...)`: Return suggested flags or values for completion. Example Usage: @@ -1379,8 +1380,12 @@ class CommandArgumentParser: usage = f"{command} {example.usage.strip()}" description = example.description.strip() block = f"[bold]{usage}[/bold]" - self.console.print(Padding(Panel(block, expand=False), (0, 2))) - self.console.print(f" {description}", style="dim") + self.console.print( + Padding( + Panel(block, expand=False, title=description, title_align="left"), + (0, 2), + ) + ) def __eq__(self, other: object) -> bool: if not isinstance(other, CommandArgumentParser): diff --git a/falyx/spinner_manager.py b/falyx/spinner_manager.py index 1f69865..d80465f 100644 --- a/falyx/spinner_manager.py +++ b/falyx/spinner_manager.py @@ -1,3 +1,4 @@ +# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed """ Centralized spinner rendering for Falyx CLI. @@ -43,13 +44,13 @@ Design Notes: """ import asyncio -import threading from rich.console import Group from rich.live import Live from rich.spinner import Spinner from falyx.console import console +from falyx.logger import logger from falyx.themes import OneColors @@ -133,7 +134,7 @@ class SpinnerManager: self._task: asyncio.Task | None = None self._running: bool = False - self._start_lock = threading.Lock() + self._lock = asyncio.Lock() async def add( self, @@ -150,9 +151,10 @@ class SpinnerManager: spinner_style=spinner_style, spinner_speed=spinner_speed, ) - with self._start_lock: + async with self._lock: if not self._running: - self._start_live() + logger.debug("[%s] Starting spinner manager Live loop.", name) + await self._start_live() def update( self, @@ -174,13 +176,17 @@ class SpinnerManager: data.spinner_type = spinner_type data.spinner = Spinner(spinner_type, text=data.text) - def remove(self, name: str): + async def remove(self, name: str): """Remove a spinner and stop the Live loop if no spinners remain.""" self._spinners.pop(name, None) - if not self._spinners: - self._running = False + async with self._lock: + if not self._spinners: + logger.debug("[%s] Stopping spinner manager, no spinners left.", name) + if self._task: + self._task.cancel() + self._running = False - def _start_live(self): + async def _start_live(self): """Start the Live rendering loop in the background.""" self._running = True self._task = asyncio.create_task(self._live_loop()) diff --git a/falyx/themes/colors.py b/falyx/themes/colors.py index ae02d1e..7b5d0de 100644 --- a/falyx/themes/colors.py +++ b/falyx/themes/colors.py @@ -1,6 +1,5 @@ +# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed """ -colors.py - A Python module that integrates the Nord color palette with the Rich library. It defines a metaclass-based NordColors class allowing dynamic attribute lookups (e.g., NORD12bu -> "#D08770 bold underline") and provides a comprehensive Nord-based diff --git a/falyx/version.py b/falyx/version.py index bd9f1fc..19a9250 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.77" +__version__ = "0.1.78" diff --git a/pyproject.toml b/pyproject.toml index da5a2de..d97f84c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "falyx" -version = "0.1.77" +version = "0.1.78" description = "Reliable and introspectable async CLI action framework." authors = ["Roland Thomas Jr "] license = "MIT" @@ -10,7 +10,7 @@ packages = [{ include = "falyx" }] [tool.poetry.dependencies] python = ">=3.10" prompt_toolkit = "^3.0" -rich = "^13.0" +rich = "^14.0" pydantic = "^2.0" python-json-logger = "^3.3.0" toml = "^0.10"