feat(spinners): integrate SpinnerManager and per-action spinners into Falyx

- Added new `SpinnerManager` module for centralized spinner rendering using Rich `Live`.
- Introduced `spinner`, `spinner_message`, `spinner_type`, `spinner_style`, and `spinner_speed` to `BaseAction` and subclasses (`Action`, `ProcessAction`, `HTTPAction`, `ActionGroup`, `ChainedAction`).
- Registered `spinner_before_hook` and `spinner_teardown_hook` automatically when `spinner=True`.
- Reworked `Command` spinner logic to use the new hook-based system instead of `console.status`.
- Updated `OptionsManager` to include a `SpinnerManager` instance for global state.
- Enhanced pipeline demo to showcase spinners across chained and grouped actions.
- Bumped version to 0.1.77.

This commit unifies spinner handling across commands, actions, and groups, making spinners consistent and automatically managed by hooks.
This commit is contained in:
2025-07-28 22:15:36 -04:00
parent 8a0a45e17f
commit f37aee568d
15 changed files with 425 additions and 30 deletions

View File

@ -1,6 +1,8 @@
import asyncio
import random
import time
from falyx import ExecutionRegistry as er
from falyx import Falyx
from falyx.action import Action, ActionGroup, ChainedAction, ProcessAction
from falyx.retry import RetryHandler, RetryPolicy
@ -17,13 +19,12 @@ def run_static_analysis():
total = 0
for i in range(10_000_000):
total += i % 3
time.sleep(5)
return total
# Step 3: Simulated flaky test with retry
async def flaky_tests():
import random
await asyncio.sleep(0.3)
if random.random() < 0.3:
raise RuntimeError("❌ Random test failure!")
@ -34,7 +35,7 @@ async def flaky_tests():
# Step 4: Multiple deploy targets (parallel ActionGroup)
async def deploy_to(target: str):
print(f"🚀 Deploying to {target}...")
await asyncio.sleep(0.2)
await asyncio.sleep(random.randint(2, 6))
return f"{target} complete"
@ -43,7 +44,12 @@ def build_pipeline():
# Base actions
checkout = Action("Checkout", checkout_code)
analysis = ProcessAction("Static Analysis", run_static_analysis)
analysis = ProcessAction(
"Static Analysis",
run_static_analysis,
spinner=True,
spinner_message="Analyzing code...",
)
tests = Action("Run Tests", flaky_tests)
tests.hooks.register("on_error", retry_handler.retry_on_error)
@ -51,9 +57,27 @@ def build_pipeline():
deploy_group = ActionGroup(
"Deploy to All",
[
Action("Deploy US", deploy_to, args=("us-west",)),
Action("Deploy EU", deploy_to, args=("eu-central",)),
Action("Deploy Asia", deploy_to, args=("asia-east",)),
Action(
"Deploy US",
deploy_to,
args=("us-west",),
spinner=True,
spinner_message="Deploying US...",
),
Action(
"Deploy EU",
deploy_to,
args=("eu-central",),
spinner=True,
spinner_message="Deploying EU...",
),
Action(
"Deploy Asia",
deploy_to,
args=("asia-east",),
spinner=True,
spinner_message="Deploying Asia...",
),
],
)
@ -66,10 +90,18 @@ pipeline = build_pipeline()
# Run the pipeline
async def main():
pipeline = build_pipeline()
await pipeline()
er.summary()
await pipeline.preview()
flx = Falyx()
flx.add_command(
"A",
"Action Thing",
pipeline,
spinner=True,
spinner_type="line",
spinner_message="Running pipeline...",
)
await flx.run()
if __name__ == "__main__":

View File

@ -83,14 +83,28 @@ class Action(BaseAction):
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
never_prompt: bool | None = None,
logging_hooks: bool = False,
retry: bool = False,
retry_policy: RetryPolicy | None = None,
spinner: bool = False,
spinner_message: str = "Processing...",
spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN,
spinner_speed: float = 1.0,
) -> None:
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
never_prompt=never_prompt,
logging_hooks=logging_hooks,
spinner=spinner,
spinner_message=spinner_message,
spinner_type=spinner_type,
spinner_style=spinner_style,
spinner_speed=spinner_speed,
)
self.action = action
self.rollback = rollback

View File

@ -101,12 +101,26 @@ class ActionGroup(BaseAction, ActionListMixin):
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
never_prompt: bool | None = None,
logging_hooks: bool = False,
spinner: bool = False,
spinner_message: str = "Processing...",
spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN,
spinner_speed: float = 1.0,
):
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
never_prompt=never_prompt,
logging_hooks=logging_hooks,
spinner=spinner,
spinner_message=spinner_message,
spinner_type=spinner_type,
spinner_style=spinner_style,
spinner_speed=spinner_speed,
)
ActionListMixin.__init__(self)
self.args = args

View File

@ -39,8 +39,10 @@ from falyx.console import console
from falyx.context import SharedContext
from falyx.debug import register_debug_hooks
from falyx.hook_manager import Hook, HookManager, HookType
from falyx.hooks import spinner_before_hook, spinner_teardown_hook
from falyx.logger import logger
from falyx.options_manager import OptionsManager
from falyx.themes import OneColors
class BaseAction(ABC):
@ -71,6 +73,11 @@ class BaseAction(ABC):
never_prompt: bool | None = None,
logging_hooks: bool = False,
ignore_in_history: bool = False,
spinner: bool = False,
spinner_message: str = "Processing...",
spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN,
spinner_speed: float = 1.0,
) -> None:
self.name = name
self.hooks = hooks or HookManager()
@ -83,6 +90,14 @@ class BaseAction(ABC):
self.console: Console = console
self.options_manager: OptionsManager | None = None
self.ignore_in_history: bool = ignore_in_history
self.spinner_message = spinner_message
self.spinner_type = spinner_type
self.spinner_style = spinner_style
self.spinner_speed = spinner_speed
if spinner:
self.hooks.register(HookType.BEFORE, spinner_before_hook)
self.hooks.register(HookType.ON_TEARDOWN, spinner_teardown_hook)
if logging_hooks:
register_debug_hooks(self.hooks)
@ -133,6 +148,13 @@ class BaseAction(ABC):
return self._never_prompt
return self.get_option("never_prompt", False)
@property
def spinner_manager(self):
"""Shortcut to access SpinnerManager via the OptionsManager."""
if not self.options_manager:
raise RuntimeError("SpinnerManager is not available (no OptionsManager set).")
return self.options_manager.spinners
def prepare(
self, shared_context: SharedContext, options_manager: OptionsManager | None = None
) -> BaseAction:

View File

@ -127,12 +127,26 @@ class ChainedAction(BaseAction, ActionListMixin):
inject_into: str = "last_result",
auto_inject: bool = False,
return_list: bool = False,
never_prompt: bool | None = None,
logging_hooks: bool = False,
spinner: bool = False,
spinner_message: str = "Processing...",
spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN,
spinner_speed: float = 1.0,
) -> None:
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
never_prompt=never_prompt,
logging_hooks=logging_hooks,
spinner=spinner,
spinner_message=spinner_message,
spinner_type=spinner_type,
spinner_style=spinner_style,
spinner_speed=spinner_speed,
)
ActionListMixin.__init__(self)
self.args = args

View File

@ -77,6 +77,11 @@ class HTTPAction(Action):
inject_into: str = "last_result",
retry: bool = False,
retry_policy=None,
spinner: bool = False,
spinner_message: str = "Processing...",
spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN,
spinner_speed: float = 1.0,
):
self.method = method.upper()
self.url = url
@ -95,6 +100,11 @@ class HTTPAction(Action):
inject_into=inject_into,
retry=retry,
retry_policy=retry_policy,
spinner=spinner,
spinner_message=spinner_message,
spinner_type=spinner_type,
spinner_style=spinner_style,
spinner_speed=spinner_speed,
)
async def _request(self, *_, **__) -> dict[str, Any]:

View File

@ -84,12 +84,26 @@ class ProcessAction(BaseAction):
executor: ProcessPoolExecutor | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
never_prompt: bool | None = None,
logging_hooks: bool = False,
spinner: bool = False,
spinner_message: str = "Processing...",
spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN,
spinner_speed: float = 1.0,
):
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
never_prompt=never_prompt,
logging_hooks=logging_hooks,
spinner=spinner,
spinner_message=spinner_message,
spinner_type=spinner_type,
spinner_style=spinner_style,
spinner_speed=spinner_speed,
)
self.action = action
self.args = args

View File

@ -82,7 +82,7 @@ class Command(BaseModel):
spinner_message (str): Spinner text message.
spinner_type (str): Spinner style (e.g., dots, line, etc.).
spinner_style (str): Color or style of the spinner.
spinner_kwargs (dict): Extra spinner configuration.
spinner_speed (float): Speed of the spinner animation.
hooks (HookManager): Hook manager for lifecycle events.
retry (bool): Enable retry on failure.
retry_all (bool): Enable retry across chained or grouped actions.
@ -128,7 +128,7 @@ class Command(BaseModel):
spinner_message: str = "Processing..."
spinner_type: str = "dots"
spinner_style: str = OneColors.CYAN
spinner_kwargs: dict[str, Any] = Field(default_factory=dict)
spinner_speed: float = 1.0
hooks: "HookManager" = Field(default_factory=HookManager)
retry: bool = False
retry_all: bool = False
@ -286,15 +286,6 @@ class Command(BaseModel):
try:
await self.hooks.trigger(HookType.BEFORE, context)
if self.spinner:
with console.status(
self.spinner_message,
spinner=self.spinner_type,
spinner_style=self.spinner_style,
**self.spinner_kwargs,
):
result = await self.action(*combined_args, **combined_kwargs)
else:
result = await self.action(*combined_args, **combined_kwargs)
context.result = result

View File

@ -120,7 +120,7 @@ class RawCommand(BaseModel):
spinner_message: str = "Processing..."
spinner_type: str = "dots"
spinner_style: str = OneColors.CYAN
spinner_kwargs: dict[str, Any] = Field(default_factory=dict)
spinner_speed: float = 1.0
before_hooks: list[Callable] = Field(default_factory=list)
success_hooks: list[Callable] = Field(default_factory=list)

View File

@ -32,6 +32,7 @@ from random import choice
from typing import Any, Callable
from prompt_toolkit import PromptSession
from prompt_toolkit.application import get_app
from prompt_toolkit.formatted_text import StyleAndTextTuples
from prompt_toolkit.history import FileHistory
from prompt_toolkit.key_binding import KeyBindings
@ -59,6 +60,7 @@ from falyx.exceptions import (
)
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import Hook, HookManager, HookType
from falyx.hooks import spinner_before_hook, spinner_teardown_hook
from falyx.logger import logger
from falyx.mode import FalyxMode
from falyx.options_manager import OptionsManager
@ -666,7 +668,7 @@ class Falyx:
spinner_message: str = "Processing...",
spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN,
spinner_kwargs: dict[str, Any] | None = None,
spinner_speed: float = 1.0,
hooks: HookManager | None = None,
before_hooks: list[Callable] | None = None,
success_hooks: list[Callable] | None = None,
@ -716,7 +718,7 @@ class Falyx:
spinner_message=spinner_message,
spinner_type=spinner_type,
spinner_style=spinner_style,
spinner_kwargs=spinner_kwargs or {},
spinner_speed=spinner_speed,
tags=tags if tags else [],
logging_hooks=logging_hooks,
retry=retry,
@ -751,6 +753,10 @@ class Falyx:
for hook in teardown_hooks or []:
command.hooks.register(HookType.ON_TEARDOWN, hook)
if spinner:
command.hooks.register(HookType.BEFORE, spinner_before_hook)
command.hooks.register(HookType.ON_TEARDOWN, spinner_teardown_hook)
self.commands[key] = command
return command
@ -948,6 +954,9 @@ class Falyx:
async def process_command(self) -> bool:
"""Processes the action of the selected command."""
app = get_app()
await asyncio.sleep(0.01)
app.invalidate()
with patch_stdout(raw=True):
choice = await self.prompt_session.prompt_async()
is_preview, selected_command, args, kwargs = await self.get_command(choice)

View File

@ -24,7 +24,6 @@ Example usage:
reporter = ResultReporter()
hooks.register(HookType.ON_SUCCESS, reporter.report)
"""
import time
from typing import Any, Callable
@ -34,6 +33,38 @@ from falyx.logger import logger
from falyx.themes import OneColors
async def spinner_before_hook(context: ExecutionContext):
"""Adds a spinner before the action starts."""
cmd = context.action
if cmd.options_manager is None:
return
sm = context.action.options_manager.spinners
if hasattr(cmd, "name"):
cmd_name = cmd.name
else:
cmd_name = cmd.key
await sm.add(
cmd_name,
cmd.spinner_message,
cmd.spinner_type,
cmd.spinner_style,
cmd.spinner_speed,
)
async def spinner_teardown_hook(context: ExecutionContext):
"""Removes the spinner after the action finishes (success or failure)."""
cmd = context.action
if cmd.options_manager is None:
return
if hasattr(cmd, "name"):
cmd_name = cmd.name
else:
cmd_name = cmd.key
sm = context.action.options_manager.spinners
sm.remove(cmd_name)
class ResultReporter:
"""Reports the success of an action."""

View File

@ -35,6 +35,7 @@ from collections import defaultdict
from typing import Any, Callable
from falyx.logger import logger
from falyx.spinner_manager import SpinnerManager
class OptionsManager:
@ -48,6 +49,7 @@ class OptionsManager:
def __init__(self, namespaces: list[tuple[str, Namespace]] | None = None) -> None:
self.options: defaultdict = defaultdict(Namespace)
self.spinners = SpinnerManager()
if namespaces:
for namespace_name, namespace in namespaces:
self.from_namespace(namespace, namespace_name)

242
falyx/spinner_manager.py Normal file
View File

@ -0,0 +1,242 @@
"""
Centralized spinner rendering for Falyx CLI.
This module provides the `SpinnerManager` class, which manages a collection of
Rich spinners that can be displayed concurrently during long-running tasks.
Key Features:
• Automatic lifecycle management:
- Starts a single Rich `Live` loop when the first spinner is added.
- Stops and clears the display when the last spinner is removed.
• Thread/async-safe start logic via a lightweight lock to prevent
duplicate Live loops from being launched.
• Supports multiple spinners running simultaneously, each with its own
text, style, type, and speed.
• Integrates with Falyx's OptionsManager so actions and commands can
declaratively request spinners without directly managing terminal state.
Classes:
SpinnerData:
Lightweight container for individual spinner settings (message,
type, style, speed) and its underlying Rich `Spinner` object.
SpinnerManager:
Manages all active spinners, handles Live rendering, and provides
methods to add, update, and remove spinners.
Example:
```python
>>> manager = SpinnerManager()
>>> await manager.add("build", "Building project…", spinner_type="dots")
>>> await manager.add("deploy", "Deploying to AWS…", spinner_type="earth")
# Both spinners animate in one unified Live panel
>>> manager.remove("build")
>>> manager.remove("deploy")
```
Design Notes:
• SpinnerManager should only create **one** Live loop at a time.
• When no spinners remain, the Live panel is cleared (`transient=True`)
so the CLI output returns to a clean state.
• Hooks in `falyx.hooks` (spinner_before_hook / spinner_teardown_hook)
call into this manager automatically when `spinner=True` is set on
an Action or Command.
"""
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.themes import OneColors
class SpinnerData:
"""
Holds the configuration and Rich spinner object for a single task.
This class is a lightweight container for spinner metadata, storing the
message text, spinner type, style, and speed. It also initializes the
corresponding Rich `Spinner` instance used by `SpinnerManager` for
rendering.
Attributes:
text (str): The message displayed next to the spinner.
spinner_type (str): The Rich spinner preset to use (e.g., "dots",
"bouncingBall", "earth").
spinner_style (str): Rich color/style for the spinner animation.
spinner (Spinner): The instantiated Rich spinner object.
Example:
```
>>> data = SpinnerData("Deploying...", spinner_type="earth",
... spinner_style="cyan", spinner_speed=1.0)
>>> data.spinner
<rich.spinner.Spinner object ...>
```
"""
def __init__(
self, text: str, spinner_type: str, spinner_style: str, spinner_speed: float
):
"""Initialize a spinner with text, type, style, and speed."""
self.text = text
self.spinner_type = spinner_type
self.spinner_style = spinner_style
self.spinner = Spinner(
spinner_type, text=text, style=spinner_style, speed=spinner_speed
)
class SpinnerManager:
"""
Manages multiple Rich spinners and handles their terminal rendering.
SpinnerManager maintains a registry of active spinners and a single
Rich `Live` display loop to render them. When the first spinner is added,
the Live loop starts automatically. When the last spinner is removed,
the Live loop stops and the panel clears (via `transient=True`).
This class is designed for integration with Falyx's `OptionsManager`
so any Action or Command can declaratively register spinners without
directly controlling terminal state.
Key Behaviors:
• Starts exactly one `Live` loop, protected by a start lock to prevent
duplicate launches in async/threaded contexts.
• Supports multiple simultaneous spinners, each with independent
text, style, and type.
• Clears the display when all spinners are removed.
Attributes:
console (Console): The Rich console used for rendering.
_spinners (dict[str, SpinnerData]): Internal store of active spinners.
_task (asyncio.Task | None): The running Live loop task, if any.
_running (bool): Indicates if the Live loop is currently active.
Example:
```
>>> manager = SpinnerManager()
>>> await manager.add("build", "Building project…")
>>> await manager.add("deploy", "Deploying services…", spinner_type="earth")
>>> manager.remove("build")
>>> manager.remove("deploy")
```
"""
def __init__(self) -> None:
"""Initialize the SpinnerManager with an empty spinner registry."""
self.console = console
self._spinners: dict[str, SpinnerData] = {}
self._task: asyncio.Task | None = None
self._running: bool = False
self._start_lock = threading.Lock()
async def add(
self,
name: str,
text: str,
spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN,
spinner_speed: float = 1.0,
):
"""Add a new spinner and start the Live loop if not already running."""
self._spinners[name] = SpinnerData(
text=text,
spinner_type=spinner_type,
spinner_style=spinner_style,
spinner_speed=spinner_speed,
)
with self._start_lock:
if not self._running:
self._start_live()
def update(
self,
name: str,
text: str | None = None,
spinner_type: str | None = None,
spinner_style: str | None = None,
):
"""Update an existing spinner's message, style, or type."""
if name in self._spinners:
data = self._spinners[name]
if text:
data.text = text
data.spinner.text = text
if spinner_style:
data.spinner_style = spinner_style
data.spinner.style = spinner_style
if spinner_type:
data.spinner_type = spinner_type
data.spinner = Spinner(spinner_type, text=data.text)
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
def _start_live(self):
"""Start the Live rendering loop in the background."""
self._running = True
self._task = asyncio.create_task(self._live_loop())
def render_panel(self):
"""Render all active spinners as a grouped Rich panel."""
rows = []
for data in self._spinners.values():
rows.append(data.spinner)
return Group(*rows)
async def _live_loop(self):
"""Continuously refresh the spinner display until stopped."""
with Live(
self.render_panel(),
refresh_per_second=12.5,
console=self.console,
transient=True,
) as live:
while self._spinners:
live.update(self.render_panel())
await asyncio.sleep(0.1)
if __name__ == "__main__":
spinner_manager = SpinnerManager()
async def demo():
# Add multiple spinners
await spinner_manager.add("task1", "Loading configs…")
await spinner_manager.add(
"task2", "Building containers…", spinner_type="bouncingBall"
)
await spinner_manager.add("task3", "Deploying services…", spinner_type="earth")
# Simulate work
await asyncio.sleep(2)
spinner_manager.update("task1", text="Configs loaded ✅")
await asyncio.sleep(1)
spinner_manager.remove("task1")
await spinner_manager.add("task4", "Running Tests...")
await asyncio.sleep(2)
spinner_manager.update("task2", text="Build complete ✅")
spinner_manager.remove("task2")
await asyncio.sleep(1)
spinner_manager.update("task3", text="Deployed! 🎉")
await asyncio.sleep(1)
spinner_manager.remove("task3")
await asyncio.sleep(5)
spinner_manager.update("task4", "Tests Complete!")
spinner_manager.remove("task4")
console.print("Done!")
asyncio.run(demo())

View File

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

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "falyx"
version = "0.1.76"
version = "0.1.77"
description = "Reliable and introspectable async CLI action framework."
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
license = "MIT"