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