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:
@ -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__":
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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]:
|
||||
|
@ -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
|
||||
|
@ -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,16 +286,7 @@ 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)
|
||||
result = await self.action(*combined_args, **combined_kwargs)
|
||||
|
||||
context.result = result
|
||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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
242
falyx/spinner_manager.py
Normal 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())
|
@ -1 +1 @@
|
||||
__version__ = "0.1.76"
|
||||
__version__ = "0.1.77"
|
||||
|
@ -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"
|
||||
|
Reference in New Issue
Block a user