From f37aee568d381377c3c2c6ff8d2dc4fc61c9b421 Mon Sep 17 00:00:00 2001 From: Roland Thomas Date: Mon, 28 Jul 2025 22:15:36 -0400 Subject: [PATCH] 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. --- examples/pipeline_demo.py | 56 ++++++-- falyx/action/action.py | 14 ++ falyx/action/action_group.py | 14 ++ falyx/action/base_action.py | 22 +++ falyx/action/chained_action.py | 14 ++ falyx/action/http_action.py | 10 ++ falyx/action/process_action.py | 14 ++ falyx/command.py | 15 +- falyx/config.py | 2 +- falyx/falyx.py | 13 +- falyx/hooks.py | 33 ++++- falyx/options_manager.py | 2 + falyx/spinner_manager.py | 242 +++++++++++++++++++++++++++++++++ falyx/version.py | 2 +- pyproject.toml | 2 +- 15 files changed, 425 insertions(+), 30 deletions(-) create mode 100644 falyx/spinner_manager.py diff --git a/examples/pipeline_demo.py b/examples/pipeline_demo.py index d31d185..83cd671 100644 --- a/examples/pipeline_demo.py +++ b/examples/pipeline_demo.py @@ -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__": diff --git a/falyx/action/action.py b/falyx/action/action.py index 6ce299f..dc67180 100644 --- a/falyx/action/action.py +++ b/falyx/action/action.py @@ -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 diff --git a/falyx/action/action_group.py b/falyx/action/action_group.py index 026a734..df8eaef 100644 --- a/falyx/action/action_group.py +++ b/falyx/action/action_group.py @@ -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 diff --git a/falyx/action/base_action.py b/falyx/action/base_action.py index ef5b192..83ace00 100644 --- a/falyx/action/base_action.py +++ b/falyx/action/base_action.py @@ -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: diff --git a/falyx/action/chained_action.py b/falyx/action/chained_action.py index c7231f3..1538f97 100644 --- a/falyx/action/chained_action.py +++ b/falyx/action/chained_action.py @@ -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 diff --git a/falyx/action/http_action.py b/falyx/action/http_action.py index 966d90a..adb5e14 100644 --- a/falyx/action/http_action.py +++ b/falyx/action/http_action.py @@ -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]: diff --git a/falyx/action/process_action.py b/falyx/action/process_action.py index dc6b178..7af4405 100644 --- a/falyx/action/process_action.py +++ b/falyx/action/process_action.py @@ -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 diff --git a/falyx/command.py b/falyx/command.py index dede937..8833cd1 100644 --- a/falyx/command.py +++ b/falyx/command.py @@ -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) diff --git a/falyx/config.py b/falyx/config.py index 3291225..6a8219d 100644 --- a/falyx/config.py +++ b/falyx/config.py @@ -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) diff --git a/falyx/falyx.py b/falyx/falyx.py index f707cb4..8d4502a 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -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) diff --git a/falyx/hooks.py b/falyx/hooks.py index 0dd3cdc..dd97538 100644 --- a/falyx/hooks.py +++ b/falyx/hooks.py @@ -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.""" diff --git a/falyx/options_manager.py b/falyx/options_manager.py index f87d6cb..bd7e327 100644 --- a/falyx/options_manager.py +++ b/falyx/options_manager.py @@ -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) diff --git a/falyx/spinner_manager.py b/falyx/spinner_manager.py new file mode 100644 index 0000000..1f69865 --- /dev/null +++ b/falyx/spinner_manager.py @@ -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 + + ``` + """ + + 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()) diff --git a/falyx/version.py b/falyx/version.py index a3f3cf2..bd9f1fc 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.76" +__version__ = "0.1.77" diff --git a/pyproject.toml b/pyproject.toml index 99ab8a0..da5a2de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] license = "MIT"