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 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.action import Action, ActionGroup, ChainedAction, ProcessAction
from falyx.retry import RetryHandler, RetryPolicy from falyx.retry import RetryHandler, RetryPolicy
@ -17,13 +19,12 @@ def run_static_analysis():
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)
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():
import random
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!")
@ -34,7 +35,7 @@ async def flaky_tests():
# 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}...") print(f"🚀 Deploying to {target}...")
await asyncio.sleep(0.2) await asyncio.sleep(random.randint(2, 6))
return f"{target} complete" return f"{target} complete"
@ -43,7 +44,12 @@ def build_pipeline():
# Base actions # Base actions
checkout = Action("Checkout", checkout_code) 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 = Action("Run Tests", flaky_tests)
tests.hooks.register("on_error", retry_handler.retry_on_error) tests.hooks.register("on_error", retry_handler.retry_on_error)
@ -51,9 +57,27 @@ def build_pipeline():
deploy_group = ActionGroup( deploy_group = ActionGroup(
"Deploy to All", "Deploy to All",
[ [
Action("Deploy US", deploy_to, args=("us-west",)), Action(
Action("Deploy EU", deploy_to, args=("eu-central",)), "Deploy US",
Action("Deploy Asia", deploy_to, args=("asia-east",)), 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 # Run the pipeline
async def main(): async def main():
pipeline = build_pipeline()
await pipeline() flx = Falyx()
er.summary() flx.add_command(
await pipeline.preview() "A",
"Action Thing",
pipeline,
spinner=True,
spinner_type="line",
spinner_message="Running pipeline...",
)
await flx.run()
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -83,14 +83,28 @@ class Action(BaseAction):
hooks: HookManager | None = None, hooks: HookManager | None = None,
inject_last_result: bool = False, inject_last_result: bool = False,
inject_into: str = "last_result", inject_into: str = "last_result",
never_prompt: bool | None = None,
logging_hooks: bool = False,
retry: bool = False, retry: bool = False,
retry_policy: RetryPolicy | None = None, 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: ) -> None:
super().__init__( super().__init__(
name, name,
hooks=hooks, hooks=hooks,
inject_last_result=inject_last_result, inject_last_result=inject_last_result,
inject_into=inject_into, 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.action = action
self.rollback = rollback self.rollback = rollback

View File

@ -101,12 +101,26 @@ class ActionGroup(BaseAction, ActionListMixin):
hooks: HookManager | None = None, hooks: HookManager | None = None,
inject_last_result: bool = False, inject_last_result: bool = False,
inject_into: str = "last_result", 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__( super().__init__(
name, name,
hooks=hooks, hooks=hooks,
inject_last_result=inject_last_result, inject_last_result=inject_last_result,
inject_into=inject_into, 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) ActionListMixin.__init__(self)
self.args = args self.args = args

View File

@ -39,8 +39,10 @@ from falyx.console import console
from falyx.context import SharedContext from falyx.context import SharedContext
from falyx.debug import register_debug_hooks from falyx.debug import register_debug_hooks
from falyx.hook_manager import Hook, HookManager, HookType 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.logger import logger
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
from falyx.themes import OneColors
class BaseAction(ABC): class BaseAction(ABC):
@ -71,6 +73,11 @@ class BaseAction(ABC):
never_prompt: bool | None = None, never_prompt: bool | None = None,
logging_hooks: bool = False, logging_hooks: bool = False,
ignore_in_history: 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: ) -> None:
self.name = name self.name = name
self.hooks = hooks or HookManager() self.hooks = hooks or HookManager()
@ -83,6 +90,14 @@ class BaseAction(ABC):
self.console: Console = console self.console: Console = console
self.options_manager: OptionsManager | None = None self.options_manager: OptionsManager | None = None
self.ignore_in_history: bool = ignore_in_history 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: if logging_hooks:
register_debug_hooks(self.hooks) register_debug_hooks(self.hooks)
@ -133,6 +148,13 @@ class BaseAction(ABC):
return self._never_prompt return self._never_prompt
return self.get_option("never_prompt", False) 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( def prepare(
self, shared_context: SharedContext, options_manager: OptionsManager | None = None self, shared_context: SharedContext, options_manager: OptionsManager | None = None
) -> BaseAction: ) -> BaseAction:

View File

@ -127,12 +127,26 @@ class ChainedAction(BaseAction, ActionListMixin):
inject_into: str = "last_result", inject_into: str = "last_result",
auto_inject: bool = False, auto_inject: bool = False,
return_list: 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: ) -> None:
super().__init__( super().__init__(
name, name,
hooks=hooks, hooks=hooks,
inject_last_result=inject_last_result, inject_last_result=inject_last_result,
inject_into=inject_into, 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) ActionListMixin.__init__(self)
self.args = args self.args = args

View File

@ -77,6 +77,11 @@ class HTTPAction(Action):
inject_into: str = "last_result", inject_into: str = "last_result",
retry: bool = False, retry: bool = False,
retry_policy=None, 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.method = method.upper()
self.url = url self.url = url
@ -95,6 +100,11 @@ class HTTPAction(Action):
inject_into=inject_into, inject_into=inject_into,
retry=retry, retry=retry,
retry_policy=retry_policy, 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]: async def _request(self, *_, **__) -> dict[str, Any]:

View File

@ -84,12 +84,26 @@ class ProcessAction(BaseAction):
executor: ProcessPoolExecutor | None = None, executor: ProcessPoolExecutor | None = None,
inject_last_result: bool = False, inject_last_result: bool = False,
inject_into: str = "last_result", 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__( super().__init__(
name, name,
hooks=hooks, hooks=hooks,
inject_last_result=inject_last_result, inject_last_result=inject_last_result,
inject_into=inject_into, 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.action = action
self.args = args self.args = args

View File

@ -82,7 +82,7 @@ class Command(BaseModel):
spinner_message (str): Spinner text message. spinner_message (str): Spinner text message.
spinner_type (str): Spinner style (e.g., dots, line, etc.). spinner_type (str): Spinner style (e.g., dots, line, etc.).
spinner_style (str): Color or style of the spinner. 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. hooks (HookManager): Hook manager for lifecycle events.
retry (bool): Enable retry on failure. retry (bool): Enable retry on failure.
retry_all (bool): Enable retry across chained or grouped actions. retry_all (bool): Enable retry across chained or grouped actions.
@ -128,7 +128,7 @@ class Command(BaseModel):
spinner_message: str = "Processing..." spinner_message: str = "Processing..."
spinner_type: str = "dots" spinner_type: str = "dots"
spinner_style: str = OneColors.CYAN 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) hooks: "HookManager" = Field(default_factory=HookManager)
retry: bool = False retry: bool = False
retry_all: bool = False retry_all: bool = False
@ -286,16 +286,7 @@ class Command(BaseModel):
try: try:
await self.hooks.trigger(HookType.BEFORE, context) await self.hooks.trigger(HookType.BEFORE, context)
if self.spinner: result = await self.action(*combined_args, **combined_kwargs)
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 context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context) await self.hooks.trigger(HookType.ON_SUCCESS, context)

View File

@ -120,7 +120,7 @@ class RawCommand(BaseModel):
spinner_message: str = "Processing..." spinner_message: str = "Processing..."
spinner_type: str = "dots" spinner_type: str = "dots"
spinner_style: str = OneColors.CYAN 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) before_hooks: list[Callable] = Field(default_factory=list)
success_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 typing import Any, Callable
from prompt_toolkit import PromptSession from prompt_toolkit import PromptSession
from prompt_toolkit.application import get_app
from prompt_toolkit.formatted_text import StyleAndTextTuples from prompt_toolkit.formatted_text import StyleAndTextTuples
from prompt_toolkit.history import FileHistory from prompt_toolkit.history import FileHistory
from prompt_toolkit.key_binding import KeyBindings 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.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import Hook, HookManager, HookType 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.logger import logger
from falyx.mode import FalyxMode from falyx.mode import FalyxMode
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
@ -666,7 +668,7 @@ class Falyx:
spinner_message: str = "Processing...", spinner_message: str = "Processing...",
spinner_type: str = "dots", spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN, spinner_style: str = OneColors.CYAN,
spinner_kwargs: dict[str, Any] | None = None, spinner_speed: float = 1.0,
hooks: HookManager | None = None, hooks: HookManager | None = None,
before_hooks: list[Callable] | None = None, before_hooks: list[Callable] | None = None,
success_hooks: list[Callable] | None = None, success_hooks: list[Callable] | None = None,
@ -716,7 +718,7 @@ class Falyx:
spinner_message=spinner_message, spinner_message=spinner_message,
spinner_type=spinner_type, spinner_type=spinner_type,
spinner_style=spinner_style, spinner_style=spinner_style,
spinner_kwargs=spinner_kwargs or {}, spinner_speed=spinner_speed,
tags=tags if tags else [], tags=tags if tags else [],
logging_hooks=logging_hooks, logging_hooks=logging_hooks,
retry=retry, retry=retry,
@ -751,6 +753,10 @@ class Falyx:
for hook in teardown_hooks or []: for hook in teardown_hooks or []:
command.hooks.register(HookType.ON_TEARDOWN, hook) 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 self.commands[key] = command
return command return command
@ -948,6 +954,9 @@ 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()
await asyncio.sleep(0.01)
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()
is_preview, selected_command, args, kwargs = await self.get_command(choice) is_preview, selected_command, args, kwargs = await self.get_command(choice)

View File

@ -24,7 +24,6 @@ Example usage:
reporter = ResultReporter() reporter = ResultReporter()
hooks.register(HookType.ON_SUCCESS, reporter.report) hooks.register(HookType.ON_SUCCESS, reporter.report)
""" """
import time import time
from typing import Any, Callable from typing import Any, Callable
@ -34,6 +33,38 @@ from falyx.logger import logger
from falyx.themes import OneColors 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: class ResultReporter:
"""Reports the success of an action.""" """Reports the success of an action."""

View File

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