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:
@ -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()
|
||||
|
@ -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()
|
||||
|
108
falyx/falyx.py
108
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)
|
||||
|
@ -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:
|
||||
|
@ -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`.
|
||||
|
@ -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):
|
||||
|
@ -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())
|
||||
|
@ -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
|
||||
|
@ -1 +1 @@
|
||||
__version__ = "0.1.77"
|
||||
__version__ = "0.1.78"
|
||||
|
@ -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"
|
||||
|
Reference in New Issue
Block a user