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 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__":
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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:
|
||||||
|
@ -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
|
||||||
|
@ -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]:
|
||||||
|
@ -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
|
||||||
|
@ -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,15 +286,6 @@ class Command(BaseModel):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await self.hooks.trigger(HookType.BEFORE, context)
|
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
|
context.result = result
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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."""
|
||||||
|
|
||||||
|
@ -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
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]
|
[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"
|
||||||
|
Reference in New Issue
Block a user