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.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()

View File

@ -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()

View File

@ -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)

View File

@ -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:

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
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_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):

View File

@ -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())

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.
It defines a metaclass-based NordColors class allowing dynamic attribute lookups
(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]
name = "falyx"
version = "0.1.77"
version = "0.1.78"
description = "Reliable and introspectable async CLI action framework."
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
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"