feat(help & spinners): improve help rendering, async spinner handling, and pipeline demo

- Refactored `Command.help_signature` to return `(usage, description, tags)` instead of a Rich `Padding`/`Panel`.
- Replaced `show_help()` with `render_help()` in `Command` and `Falyx`.
- Updated Falyx help rendering to use Rich `Panel`/`Padding` consistently for cleaner UI.
- Swapped `print()` calls for `console.print()` for styled output.
- Added hooks to `ProcessAction` to announce analysis start/finish.
- Added spinners to test and deploy steps; simplified retry setup.
- Converted `remove()` to `async def remove()` for consistency.
- Added async lock to prevent concurrent Live loop start/stop races.
- Added debug logging when starting/stopping the Live loop.
- Updated `spinner_teardown_hook` to `await sm.remove(...)` to align with async `remove()`.
- Removed `rich.panel`/`rich.padding` from `Command` since panels are now built in `Falyx` help rendering.
- Bumped `rich` dependency to `^14.0`.
- Bumped version to 0.1.78.

This commit polishes help display, demo UX, and spinner lifecycle safety—making spinners thread/async safe and help output more structured and readable.
This commit is contained in:
2025-07-30 22:24:55 -04:00
parent f37aee568d
commit 3b2c33d28f
10 changed files with 134 additions and 90 deletions

View File

@ -4,44 +4,44 @@ import time
from falyx import Falyx from falyx import Falyx
from falyx.action import Action, ActionGroup, ChainedAction, ProcessAction 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) # Step 1: Fast I/O-bound setup (standard Action)
async def checkout_code(): async def checkout_code():
print("📥 Checking out code...") console.print("🔄 Checking out code...")
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
console.print("📦 Code checked out successfully.")
# Step 2: CPU-bound task (ProcessAction) # Step 2: CPU-bound task (ProcessAction)
def run_static_analysis(): def run_static_analysis():
print("🧠 Running static analysis (CPU-bound)...")
total = 0 total = 0
for i in range(10_000_000): for i in range(10_000_000):
total += i % 3 total += i % 3
time.sleep(5) time.sleep(2)
return total return total
# Step 3: Simulated flaky test with retry # Step 3: Simulated flaky test with retry
async def flaky_tests(): async def flaky_tests():
console.print("🧪 Running tests...")
await asyncio.sleep(0.3) await asyncio.sleep(0.3)
if random.random() < 0.3: if random.random() < 0.3:
raise RuntimeError("❌ Random test failure!") raise RuntimeError("❌ Random test failure!")
print("🧪 Tests passed.") console.print("🧪 Tests passed.")
return "ok" return "ok"
# Step 4: Multiple deploy targets (parallel ActionGroup) # Step 4: Multiple deploy targets (parallel ActionGroup)
async def deploy_to(target: str): async def deploy_to(target: str):
print(f"🚀 Deploying to {target}...") console.print(f"🚀 Deploying to {target}...")
await asyncio.sleep(random.randint(2, 6)) await asyncio.sleep(random.randint(2, 6))
console.print(f"✅ Deployment to {target} complete.")
return f"{target} complete" return f"{target} complete"
def build_pipeline(): def build_pipeline():
retry_handler = RetryHandler(RetryPolicy(max_retries=3, delay=0.5))
# Base actions # Base actions
checkout = Action("Checkout", checkout_code) checkout = Action("Checkout", checkout_code)
analysis = ProcessAction( analysis = ProcessAction(
@ -50,8 +50,17 @@ def build_pipeline():
spinner=True, spinner=True,
spinner_message="Analyzing code...", spinner_message="Analyzing code...",
) )
tests = Action("Run Tests", flaky_tests) analysis.hooks.register(
tests.hooks.register("on_error", retry_handler.retry_on_error) "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 # Parallel deploys
deploy_group = ActionGroup( deploy_group = ActionGroup(
@ -91,14 +100,18 @@ pipeline = build_pipeline()
# Run the pipeline # Run the pipeline
async def main(): async def main():
flx = Falyx() flx = Falyx(
hide_menu_table=True, program="pipeline_demo.py", show_placeholder_menu=True
)
flx.add_command( flx.add_command(
"A", "P",
"Action Thing", "Run Pipeline",
pipeline, pipeline,
spinner=True, spinner=True,
spinner_type="line", spinner_type="line",
spinner_message="Running pipeline...", spinner_message="Running pipeline...",
tags=["pipeline", "demo"],
help_text="Run the full CI/CD pipeline demo.",
) )
await flx.run() await flx.run()

View File

@ -22,8 +22,6 @@ from typing import Any, Awaitable, Callable
from prompt_toolkit.formatted_text import FormattedText from prompt_toolkit.formatted_text import FormattedText
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator 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 rich.tree import Tree
from falyx.action.action import Action from falyx.action.action import Action
@ -342,7 +340,7 @@ class Command(BaseModel):
return f" {command_keys_text:<20} {options_text} " return f" {command_keys_text:<20} {options_text} "
@property @property
def help_signature(self) -> tuple[Padding, str]: def help_signature(self) -> tuple[str, str, str]:
"""Generate a help signature for the command.""" """Generate a help signature for the command."""
is_cli_mode = self.options_manager.get("mode") in { is_cli_mode = self.options_manager.get("mode") in {
FalyxMode.RUN, FalyxMode.RUN,
@ -353,30 +351,21 @@ class Command(BaseModel):
program = f"{self.program} run " if is_cli_mode else "" program = f"{self.program} run " if is_cli_mode else ""
if self.arg_parser and not self.simple_help_signature: if self.arg_parser and not self.simple_help_signature:
usage = Padding( usage = f"[{self.style}]{program}[/]{self.arg_parser.get_usage()}"
Panel( description = f"[dim]{self.help_text or self.description}[/dim]"
f"[{self.style}]{program}[/]{self.arg_parser.get_usage()}",
expand=False,
),
(0, 2),
)
description = [f" [dim]{self.help_text or self.description}[/dim]"]
if self.tags: if self.tags:
description.append(f" [dim]Tags: {', '.join(self.tags)}[/dim]") tags = f"[dim]Tags: {', '.join(self.tags)}[/dim]"
return usage, "\n".join(description) else:
tags = ""
return usage, description, tags
command_keys = " | ".join( command_keys = " | ".join(
[f"[{self.style}]{self.key}[/{self.style}]"] [f"[{self.style}]{self.key}[/{self.style}]"]
+ [f"[{self.style}]{alias}[/{self.style}]" for alias in self.aliases] + [f"[{self.style}]{alias}[/{self.style}]" for alias in self.aliases]
) )
return ( return (
Padding( f"[{self.style}]{program}[/]{command_keys}",
Panel( f"[dim]{self.description}[/dim]",
f"[{self.style}]{program}[/]{command_keys} {self.description}",
expand=False,
),
(0, 2),
),
"", "",
) )
@ -384,7 +373,7 @@ class Command(BaseModel):
if self._context: if self._context:
self._context.log_summary() self._context.log_summary()
def show_help(self) -> bool: def render_help(self) -> bool:
"""Display the help message for the command.""" """Display the help message for the command."""
if callable(self.custom_help): if callable(self.custom_help):
output = self.custom_help() output = self.custom_help()

View File

@ -41,6 +41,8 @@ from prompt_toolkit.validation import ValidationError
from rich import box from rich import box
from rich.console import Console from rich.console import Console
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.padding import Padding
from rich.panel import Panel
from rich.table import Table from rich.table import Table
from falyx.action.action import Action from falyx.action.action import Action
@ -347,71 +349,97 @@ class Falyx:
def get_tip(self) -> str: def get_tip(self) -> str:
program = f"{self.program} run " if self.is_cli_mode else "" program = f"{self.program} run " if self.is_cli_mode else ""
tips = [ tips = [
f"Tip: Use '{program}?[COMMAND]' to preview a command.", f"Use '{program}?[COMMAND]' to preview a command.",
"Tip: Every command supports aliases—try abbreviating the name!", "Every command supports aliases—try abbreviating the name!",
f"Tip: Use '{program}H' to reopen this help menu anytime.", f"Use '{program}H' to reopen this help menu anytime.",
f"Tip: '{program}[COMMAND] --help' prints a detailed help message.", f"'{program}[COMMAND] --help' prints a detailed help message.",
"Tip: [bold]CLI[/] and [bold]Menu[/] mode—commands run the same way in both.", "[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"'{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"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"'{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"Run commands directly from the CLI: '{self.program} run [COMMAND] [OPTIONS]'.",
] ]
if self.is_cli_mode: if self.is_cli_mode:
tips.extend( tips.extend(
[ [
f"Tip: Use '{self.program} run ?' to list all commands at any time.", f"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"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"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"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.", f"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.", "Use '--skip-confirm' for automation scripts where no prompts are wanted.",
] ]
) )
else: else:
tips.extend( tips.extend(
[ [
"Tip: Use '[?]' alone to list all commands at any time.", "Use '[?]' alone to list all commands at any time.",
"Tip: '[CTRL+KEY]' toggles are available in menu mode for quick switches.", "'[CTRL+KEY]' toggles are available in menu mode for quick switches.",
"Tip: '[Y]' opens the command history viewer.", "'[Y]' opens the command history viewer.",
"Tip: Use '[X]' in menu mode to exit.", "Use '[X]' in menu mode to exit.",
] ]
) )
return choice(tips) return choice(tips)
async def _show_help(self, tag: str = "") -> None: async def _render_help(self, tag: str = "") -> None:
self.console.print("[bold]help:[/bold]")
if tag: if tag:
tag_lower = tag.lower() tag_lower = tag.lower()
self.console.print(f"[bold]{tag_lower}:[/bold]")
commands = [ commands = [
command command
for command in self.commands.values() for command in self.commands.values()
if any(tag_lower == tag.lower() for tag in command.tags) 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: for command in commands:
usage, description = command.help_signature usage, description, _ = command.help_signature
self.console.print(usage) self.console.print(usage)
if description: if description:
self.console.print(description) self.console.print(description)
return return
self.console.print("[bold]help:[/bold]")
for command in self.commands.values(): for command in self.commands.values():
usage, description = command.help_signature usage, description, tag = command.help_signature
self.console.print(usage) self.console.print(
if description: Padding(
self.console.print(description) Panel(
usage,
expand=False,
title=description,
title_align="left",
subtitle=tag,
),
(0, 2),
)
)
if self.help_command: if self.help_command:
usage, description = self.help_command.help_signature usage, description, _ = self.help_command.help_signature
self.console.print(usage) self.console.print(
self.console.print(description) Padding(
Panel(usage, expand=False, title=description, title_align="left"),
(0, 2),
)
)
if not self.is_cli_mode: if not self.is_cli_mode:
if self.history_command: if self.history_command:
usage, description = self.history_command.help_signature usage, description, _ = self.history_command.help_signature
self.console.print(usage) self.console.print(
self.console.print(description) Padding(
usage, _ = self.exit_command.help_signature Panel(usage, expand=False, title=description, title_align="left"),
self.console.print(usage) (0, 2),
self.console.print(self.get_tip()) )
)
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: def _get_help_command(self) -> Command:
"""Returns the help command for the menu.""" """Returns the help command for the menu."""
@ -434,7 +462,7 @@ class Falyx:
aliases=["?", "HELP", "LIST"], aliases=["?", "HELP", "LIST"],
description="Help", description="Help",
help_text="Show this help menu", help_text="Show this help menu",
action=Action("Help", self._show_help), action=Action("Help", self._render_help),
style=OneColors.LIGHT_YELLOW, style=OneColors.LIGHT_YELLOW,
arg_parser=parser, arg_parser=parser,
ignore_in_history=True, ignore_in_history=True,
@ -884,7 +912,7 @@ class Falyx:
args, kwargs = await run_command.parse_args(input_args, from_validate) args, kwargs = await run_command.parse_args(input_args, from_validate)
except (CommandArgumentError, Exception) as error: except (CommandArgumentError, Exception) as error:
if not from_validate: if not from_validate:
run_command.show_help() run_command.render_help()
self.console.print( self.console.print(
f"[{OneColors.DARK_RED}]❌ [{run_command.key}]: {error}" f"[{OneColors.DARK_RED}]❌ [{run_command.key}]: {error}"
) )
@ -955,7 +983,7 @@ class Falyx:
async def process_command(self) -> bool: async def process_command(self) -> bool:
"""Processes the action of the selected command.""" """Processes the action of the selected command."""
app = get_app() app = get_app()
await asyncio.sleep(0.01) await asyncio.sleep(0.1)
app.invalidate() app.invalidate()
with patch_stdout(raw=True): with patch_stdout(raw=True):
choice = await self.prompt_session.prompt_async() choice = await self.prompt_session.prompt_async()
@ -1171,7 +1199,7 @@ class Falyx:
self.register_all_with_debug_hooks() self.register_all_with_debug_hooks()
if self.cli_args.command == "list": 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) sys.exit(0)
if self.cli_args.command == "version" or self.cli_args.version: if self.cli_args.command == "version" or self.cli_args.version:
@ -1210,7 +1238,7 @@ class Falyx:
sys.exit(0) sys.exit(0)
except CommandArgumentError as error: except CommandArgumentError as error:
self.console.print(f"[{OneColors.DARK_RED}]❌ ['{command.key}'] {error}") self.console.print(f"[{OneColors.DARK_RED}]❌ ['{command.key}'] {error}")
command.show_help() command.render_help()
sys.exit(1) sys.exit(1)
try: try:
await self.run_key(self.cli_args.name, args=args, kwargs=kwargs) await self.run_key(self.cli_args.name, args=args, kwargs=kwargs)

View File

@ -3,6 +3,8 @@
Defines reusable lifecycle hooks for Falyx Actions and Commands. Defines reusable lifecycle hooks for Falyx Actions and Commands.
This module includes: 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. - `ResultReporter`: A success hook that displays a formatted result with duration.
- `CircuitBreaker`: A failure-aware hook manager that prevents repeated execution - `CircuitBreaker`: A failure-aware hook manager that prevents repeated execution
after a configurable number of failures. 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. (`before`, `on_error`, `after`, etc.) to enhance resiliency and observability.
Intended for use with: Intended for use with:
- Actions that require user feedback during long-running operations.
- Retryable or unstable actions - Retryable or unstable actions
- Interactive CLI feedback - Interactive CLI feedback
- Safety checks prior to execution - Safety checks prior to execution
@ -62,7 +65,7 @@ async def spinner_teardown_hook(context: ExecutionContext):
else: else:
cmd_name = cmd.key cmd_name = cmd.key
sm = context.action.options_manager.spinners sm = context.action.options_manager.spinners
sm.remove(cmd_name) await sm.remove(cmd_name)
class ResultReporter: class ResultReporter:

View File

@ -1,3 +1,4 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
""" """
Defines `MenuOption` and `MenuOptionMap`, core components used to construct Defines `MenuOption` and `MenuOptionMap`, core components used to construct
interactive menus within Falyx Actions such as `MenuAction` and `PromptMenuAction`. interactive menus within Falyx Actions such as `MenuAction` and `PromptMenuAction`.

View File

@ -24,6 +24,7 @@ Public Interface:
- `parse_args(...)`: Parse CLI-style argument list into a `dict[str, Any]`. - `parse_args(...)`: Parse CLI-style argument list into a `dict[str, Any]`.
- `parse_args_split(...)`: Return `(*args, **kwargs)` for Action invocation. - `parse_args_split(...)`: Return `(*args, **kwargs)` for Action invocation.
- `render_help()`: Render a rich-styled help panel. - `render_help()`: Render a rich-styled help panel.
- `render_tldr()`: Render quick usage examples.
- `suggest_next(...)`: Return suggested flags or values for completion. - `suggest_next(...)`: Return suggested flags or values for completion.
Example Usage: Example Usage:
@ -1379,8 +1380,12 @@ class CommandArgumentParser:
usage = f"{command} {example.usage.strip()}" usage = f"{command} {example.usage.strip()}"
description = example.description.strip() description = example.description.strip()
block = f"[bold]{usage}[/bold]" block = f"[bold]{usage}[/bold]"
self.console.print(Padding(Panel(block, expand=False), (0, 2))) self.console.print(
self.console.print(f" {description}", style="dim") Padding(
Panel(block, expand=False, title=description, title_align="left"),
(0, 2),
)
)
def __eq__(self, other: object) -> bool: def __eq__(self, other: object) -> bool:
if not isinstance(other, CommandArgumentParser): if not isinstance(other, CommandArgumentParser):

View File

@ -1,3 +1,4 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
""" """
Centralized spinner rendering for Falyx CLI. Centralized spinner rendering for Falyx CLI.
@ -43,13 +44,13 @@ Design Notes:
""" """
import asyncio import asyncio
import threading
from rich.console import Group from rich.console import Group
from rich.live import Live from rich.live import Live
from rich.spinner import Spinner from rich.spinner import Spinner
from falyx.console import console from falyx.console import console
from falyx.logger import logger
from falyx.themes import OneColors from falyx.themes import OneColors
@ -133,7 +134,7 @@ class SpinnerManager:
self._task: asyncio.Task | None = None self._task: asyncio.Task | None = None
self._running: bool = False self._running: bool = False
self._start_lock = threading.Lock() self._lock = asyncio.Lock()
async def add( async def add(
self, self,
@ -150,9 +151,10 @@ class SpinnerManager:
spinner_style=spinner_style, spinner_style=spinner_style,
spinner_speed=spinner_speed, spinner_speed=spinner_speed,
) )
with self._start_lock: async with self._lock:
if not self._running: if not self._running:
self._start_live() logger.debug("[%s] Starting spinner manager Live loop.", name)
await self._start_live()
def update( def update(
self, self,
@ -174,13 +176,17 @@ class SpinnerManager:
data.spinner_type = spinner_type data.spinner_type = spinner_type
data.spinner = Spinner(spinner_type, text=data.text) 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.""" """Remove a spinner and stop the Live loop if no spinners remain."""
self._spinners.pop(name, None) self._spinners.pop(name, None)
if not self._spinners: async with self._lock:
self._running = False 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.""" """Start the Live rendering loop in the background."""
self._running = True self._running = True
self._task = asyncio.create_task(self._live_loop()) self._task = asyncio.create_task(self._live_loop())

View File

@ -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. A Python module that integrates the Nord color palette with the Rich library.
It defines a metaclass-based NordColors class allowing dynamic attribute lookups It defines a metaclass-based NordColors class allowing dynamic attribute lookups
(e.g., NORD12bu -> "#D08770 bold underline") and provides a comprehensive Nord-based (e.g., NORD12bu -> "#D08770 bold underline") and provides a comprehensive Nord-based

View File

@ -1 +1 @@
__version__ = "0.1.77" __version__ = "0.1.78"

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "falyx" name = "falyx"
version = "0.1.77" version = "0.1.78"
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"
@ -10,7 +10,7 @@ packages = [{ include = "falyx" }]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = ">=3.10" python = ">=3.10"
prompt_toolkit = "^3.0" prompt_toolkit = "^3.0"
rich = "^13.0" rich = "^14.0"
pydantic = "^2.0" pydantic = "^2.0"
python-json-logger = "^3.3.0" python-json-logger = "^3.3.0"
toml = "^0.10" toml = "^0.10"