From e91654ca275ef11014226bccc4a84794f2c85e6c Mon Sep 17 00:00:00 2001 From: Roland Thomas Date: Thu, 1 May 2025 20:26:50 -0400 Subject: [PATCH] Linting, pre-commit --- .gitignore | 1 - examples/action_example.py | 3 + examples/process_pool.py | 5 +- examples/simple.py | 2 + falyx/__init__.py | 1 + falyx/__main__.py | 5 +- falyx/action.py | 111 ++++++++----- falyx/bottom_bar.py | 26 +-- falyx/command.py | 35 ++-- falyx/config.py | 6 +- falyx/context.py | 13 +- falyx/exceptions.py | 2 +- falyx/execution_registry.py | 14 +- falyx/falyx.py | 248 +++++++++++++++++++--------- falyx/hook_manager.py | 15 +- falyx/hooks.py | 22 ++- falyx/http_action.py | 1 + falyx/io_action.py | 37 +++-- falyx/options_manager.py | 4 +- falyx/parsers.py | 113 +++++++++---- falyx/retry.py | 15 +- falyx/themes/colors.py | 59 ++++--- falyx/utils.py | 32 ++-- falyx/version.py | 2 +- pyproject.toml | 8 +- scripts/sync_version.py | 7 +- tests/test_action_basic.py | 49 ++++-- tests/test_action_process.py | 8 +- tests/test_action_retries.py | 2 + tests/test_actions.py | 253 +++++++++++++++++++++-------- tests/test_chained_action_empty.py | 3 +- tests/test_command.py | 53 +++--- tests/test_stress_actions.py | 8 +- 33 files changed, 795 insertions(+), 368 deletions(-) diff --git a/.gitignore b/.gitignore index fcf02f9..37c3e6f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,3 @@ build/ .vscode/ coverage.xml .coverage - diff --git a/examples/action_example.py b/examples/action_example.py index 644043e..9002b71 100644 --- a/examples/action_example.py +++ b/examples/action_example.py @@ -8,15 +8,18 @@ from falyx import Action, ActionGroup, ChainedAction def hello() -> None: print("Hello, world!") + hello = Action(name="hello_action", action=hello) # Actions can be run by themselves or as part of a command or pipeline asyncio.run(hello()) + # Actions are designed to be asynchronous first async def goodbye() -> None: print("Goodbye!") + goodbye = Action(name="goodbye_action", action=goodbye) asyncio.run(goodbye()) diff --git a/examples/process_pool.py b/examples/process_pool.py index c42b29d..dadf71c 100644 --- a/examples/process_pool.py +++ b/examples/process_pool.py @@ -1,10 +1,12 @@ +from rich.console import Console + from falyx import Falyx, ProcessAction from falyx.themes.colors import NordColors as nc -from rich.console import Console console = Console() falyx = Falyx(title="🚀 Process Pool Demo") + def generate_primes(n): primes = [] for num in range(2, n): @@ -13,6 +15,7 @@ def generate_primes(n): console.print(f"Generated {len(primes)} primes up to {n}.", style=nc.GREEN) return primes + # Will not block the event loop heavy_action = ProcessAction("Prime Generator", generate_primes, args=(100_000,)) diff --git a/examples/simple.py b/examples/simple.py index 0ccb5e3..2d9464f 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -6,6 +6,7 @@ from falyx.utils import setup_logging setup_logging() + # A flaky async step that fails randomly async def flaky_step(): await asyncio.sleep(0.2) @@ -13,6 +14,7 @@ async def flaky_step(): raise RuntimeError("Random failure!") return "ok" + # Create a retry handler step1 = Action(name="step_1", action=flaky_step, retry=True) step2 = Action(name="step_2", action=flaky_step, retry=True) diff --git a/falyx/__init__.py b/falyx/__init__.py index 2ebe73f..02d0227 100644 --- a/falyx/__init__.py +++ b/falyx/__init__.py @@ -4,6 +4,7 @@ Falyx CLI Framework Copyright (c) 2025 rtj.dev LLC. Licensed under the MIT License. See LICENSE file for details. """ + import logging from .action import Action, ActionGroup, ChainedAction, ProcessAction diff --git a/falyx/__main__.py b/falyx/__main__.py index 59f509c..fc33a35 100644 --- a/falyx/__main__.py +++ b/falyx/__main__.py @@ -4,6 +4,7 @@ Falyx CLI Framework Copyright (c) 2025 rtj.dev LLC. Licensed under the MIT License. See LICENSE file for details. """ + import asyncio import random from argparse import Namespace @@ -131,7 +132,7 @@ async def main() -> None: Action("Clean", foo.clean), Action("Build", foo.build_package), Action("Package", foo.package), - ] + ], ) flx.add_command( key="P", @@ -150,7 +151,7 @@ async def main() -> None: Action("Unit Tests", foo.run_tests), Action("Integration Tests", foo.run_integration_tests), Action("Lint", foo.run_linter), - ] + ], ) flx.add_command( key="G", diff --git a/falyx/action.py b/falyx/action.py index 388657a..a198a10 100644 --- a/falyx/action.py +++ b/falyx/action.py @@ -59,13 +59,14 @@ class BaseAction(ABC): (default: 'last_result'). _requires_injection (bool): Whether the action requires input injection. """ + def __init__( - self, - name: str, - hooks: HookManager | None = None, - inject_last_result: bool = False, - inject_last_result_as: str = "last_result", - logging_hooks: bool = False, + self, + name: str, + hooks: HookManager | None = None, + inject_last_result: bool = False, + inject_last_result_as: str = "last_result", + logging_hooks: bool = False, ) -> None: self.name = name self.hooks = hooks or HookManager() @@ -156,18 +157,19 @@ class Action(BaseAction): retry (bool, optional): Enable retry logic. retry_policy (RetryPolicy, optional): Retry settings. """ + def __init__( - self, - name: str, - action, - rollback=None, - args: tuple[Any, ...] = (), - kwargs: dict[str, Any] | None = None, - hooks: HookManager | None = None, - inject_last_result: bool = False, - inject_last_result_as: str = "last_result", - retry: bool = False, - retry_policy: RetryPolicy | None = None, + self, + name: str, + action, + rollback=None, + args: tuple[Any, ...] = (), + kwargs: dict[str, Any] | None = None, + hooks: HookManager | None = None, + inject_last_result: bool = False, + inject_last_result_as: str = "last_result", + retry: bool = False, + retry_policy: RetryPolicy | None = None, ) -> None: super().__init__(name, hooks, inject_last_result, inject_last_result_as) self.action = action @@ -264,10 +266,13 @@ class LiteralInputAction(Action): Args: value (Any): The static value to inject. """ + def __init__(self, value: Any): self._value = value + async def literal(*args, **kwargs): return value + super().__init__("Input", literal) @cached_property @@ -293,10 +298,13 @@ class FallbackAction(Action): Args: fallback (Any): The fallback value to use if last_result is None. """ + def __init__(self, fallback: Any): self._fallback = fallback + async def _fallback_logic(last_result): return last_result if last_result is not None else fallback + super().__init__(name="Fallback", action=_fallback_logic, inject_last_result=True) @cached_property @@ -310,6 +318,7 @@ class FallbackAction(Action): class ActionListMixin: """Mixin for managing a list of actions.""" + def __init__(self) -> None: self.actions: list[BaseAction] = [] @@ -360,15 +369,16 @@ class ChainedAction(BaseAction, ActionListMixin): auto_inject (bool, optional): Auto-enable injection for subsequent actions. return_list (bool, optional): Whether to return a list of all results. False returns the last result. """ + def __init__( - self, - name: str, - actions: list[BaseAction | Any] | None = None, - hooks: HookManager | None = None, - inject_last_result: bool = False, - inject_last_result_as: str = "last_result", - auto_inject: bool = False, - return_list: bool = False, + self, + name: str, + actions: list[BaseAction | Any] | None = None, + hooks: HookManager | None = None, + inject_last_result: bool = False, + inject_last_result_as: str = "last_result", + auto_inject: bool = False, + return_list: bool = False, ) -> None: super().__init__(name, hooks, inject_last_result, inject_last_result_as) ActionListMixin.__init__(self) @@ -378,7 +388,9 @@ class ChainedAction(BaseAction, ActionListMixin): self.set_actions(actions) def _wrap_literal_if_needed(self, action: BaseAction | Any) -> BaseAction: - return LiteralInputAction(action) if not isinstance(action, BaseAction) else action + return ( + LiteralInputAction(action) if not isinstance(action, BaseAction) else action + ) def add_action(self, action: BaseAction | Any) -> None: action = self._wrap_literal_if_needed(action) @@ -408,23 +420,35 @@ class ChainedAction(BaseAction, ActionListMixin): for index, action in enumerate(self.actions): if action._skip_in_chain: - logger.debug("[%s] ⚠️ Skipping consumed action '%s'", self.name, action.name) + logger.debug( + "[%s] ⚠️ Skipping consumed action '%s'", self.name, action.name + ) continue shared_context.current_index = index prepared = action.prepare_for_chain(shared_context) last_result = shared_context.last_result() try: if self.requires_io_injection() and last_result is not None: - result = await prepared(**{prepared.inject_last_result_as: last_result}) + result = await prepared( + **{prepared.inject_last_result_as: last_result} + ) else: result = await prepared(*args, **updated_kwargs) except Exception as error: - if index + 1 < len(self.actions) and isinstance(self.actions[index + 1], FallbackAction): - logger.warning("[%s] ⚠️ Fallback triggered: %s, recovering with fallback '%s'.", - self.name, error, self.actions[index + 1].name) + if index + 1 < len(self.actions) and isinstance( + self.actions[index + 1], FallbackAction + ): + logger.warning( + "[%s] ⚠️ Fallback triggered: %s, recovering with fallback '%s'.", + self.name, + error, + self.actions[index + 1].name, + ) shared_context.add_result(None) context.extra["results"].append(None) - fallback = self.actions[index + 1].prepare_for_chain(shared_context) + fallback = self.actions[index + 1].prepare_for_chain( + shared_context + ) result = await fallback() fallback._skip_in_chain = True else: @@ -434,7 +458,9 @@ class ChainedAction(BaseAction, ActionListMixin): context.extra["rollback_stack"].append(prepared) all_results = context.extra["results"] - assert all_results, f"[{self.name}] No results captured. Something seriously went wrong." + assert ( + all_results + ), f"[{self.name}] No results captured. Something seriously went wrong." context.result = all_results if self.return_list else all_results[-1] await self.hooks.trigger(HookType.ON_SUCCESS, context) return context.result @@ -528,13 +554,14 @@ class ActionGroup(BaseAction, ActionListMixin): inject_last_result (bool, optional): Whether to inject last results into kwargs by default. inject_last_result_as (str, optional): Key name for injection. """ + def __init__( - self, - name: str, - actions: list[BaseAction] | None = None, - hooks: HookManager | None = None, - inject_last_result: bool = False, - inject_last_result_as: str = "last_result", + self, + name: str, + actions: list[BaseAction] | None = None, + hooks: HookManager | None = None, + inject_last_result: bool = False, + inject_last_result_as: str = "last_result", ): super().__init__(name, hooks, inject_last_result, inject_last_result_as) ActionListMixin.__init__(self) @@ -554,6 +581,7 @@ class ActionGroup(BaseAction, ActionListMixin): extra={"results": [], "errors": []}, shared_context=shared_context, ) + async def run_one(action: BaseAction): try: prepared = action.prepare_for_group(shared_context) @@ -692,7 +720,9 @@ class ProcessAction(BaseAction): er.record(context) async def preview(self, parent: Tree | None = None): - label = [f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessAction (new process)[/] '{self.name}'"] + label = [ + f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessAction (new process)[/] '{self.name}'" + ] if self.inject_last_result: label.append(f" [dim](injects '{self.inject_last_result_as}')[/dim]") if parent: @@ -703,6 +733,7 @@ class ProcessAction(BaseAction): def _validate_pickleable(self, obj: Any) -> bool: try: import pickle + pickle.dumps(obj) return True except (pickle.PicklingError, TypeError): diff --git a/falyx/bottom_bar.py b/falyx/bottom_bar.py index bdb76a4..fab4233 100644 --- a/falyx/bottom_bar.py +++ b/falyx/bottom_bar.py @@ -45,11 +45,7 @@ class BottomBar: def space(self) -> int: return self.console.width // self.columns - def add_custom( - self, - name: str, - render_fn: Callable[[], HTML] - ) -> None: + def add_custom(self, name: str, render_fn: Callable[[], HTML]) -> None: """Add a custom render function to the bottom bar.""" if not callable(render_fn): raise ValueError("`render_fn` must be callable") @@ -63,9 +59,7 @@ class BottomBar: bg: str = OneColors.WHITE, ) -> None: def render(): - return HTML( - f"" - ) + return HTML(f"") self._add_named(name, render) @@ -85,9 +79,7 @@ class BottomBar: get_value_ = self._value_getters[name] current_ = get_value_() text = f"{label}: {current_}" - return HTML( - f"" - ) + return HTML(f"") self._add_named(name, render) @@ -114,9 +106,7 @@ class BottomBar: f"Current value {current_value} is greater than total value {total}" ) text = f"{label}: {current_value}/{total}" - return HTML( - f"" - ) + return HTML(f"") self._add_named(name, render) @@ -138,7 +128,9 @@ class BottomBar: if key in self.toggle_keys: raise ValueError(f"Key {key} is already used as a toggle") if self.key_validator and not self.key_validator(key): - raise ValueError(f"Key '{key}' conflicts with existing command, toggle, or reserved key.") + raise ValueError( + f"Key '{key}' conflicts with existing command, toggle, or reserved key." + ) self._value_getters[key] = get_state self.toggle_keys.append(key) @@ -147,9 +139,7 @@ class BottomBar: color = bg_on if get_state_() else bg_off status = "ON" if get_state_() else "OFF" text = f"({key.upper()}) {label}: {status}" - return HTML( - f"" - ) + return HTML(f"") self._add_named(key, render) diff --git a/falyx/command.py b/falyx/command.py index e3eaf9b..4936b47 100644 --- a/falyx/command.py +++ b/falyx/command.py @@ -44,7 +44,7 @@ class Command(BaseModel): """ Represents a selectable command in a Falyx menu system. - A Command wraps an executable action (function, coroutine, or BaseAction) + A Command wraps an executable action (function, coroutine, or BaseAction) and enhances it with: - Lifecycle hooks (before, success, error, after, teardown) @@ -91,6 +91,7 @@ class Command(BaseModel): result: Property exposing the last result. log_summary(): Summarizes execution details to the console. """ + key: str description: str action: BaseAction | Callable[[], Any] = _noop @@ -127,12 +128,16 @@ class Command(BaseModel): elif self.retry_policy and isinstance(self.action, Action): self.action.set_retry_policy(self.retry_policy) elif self.retry: - logger.warning(f"[Command:{self.key}] Retry requested, but action is not an Action instance.") + logger.warning( + f"[Command:{self.key}] Retry requested, but action is not an Action instance." + ) if self.retry_all and isinstance(self.action, BaseAction): self.retry_policy.enabled = True enable_retries_recursively(self.action, self.retry_policy) elif self.retry_all: - logger.warning(f"[Command:{self.key}] Retry all requested, but action is not a BaseAction instance.") + logger.warning( + f"[Command:{self.key}] Retry all requested, but action is not a BaseAction instance." + ) if self.logging_hooks and isinstance(self.action, BaseAction): register_debug_hooks(self.action.hooks) @@ -149,7 +154,11 @@ class Command(BaseModel): if isinstance(self.action, BaseIOAction): return True elif isinstance(self.action, ChainedAction): - return isinstance(self.action.actions[0], BaseIOAction) if self.action.actions else False + return ( + isinstance(self.action.actions[0], BaseIOAction) + if self.action.actions + else False + ) elif isinstance(self.action, ActionGroup): return any(isinstance(action, BaseIOAction) for action in self.action.actions) return False @@ -164,8 +173,10 @@ class Command(BaseModel): raise TypeError("Action must be a callable or an instance of BaseAction") def __str__(self): - return (f"Command(key='{self.key}', description='{self.description}' " - f"action='{self.action}')") + return ( + f"Command(key='{self.key}', description='{self.description}' " + f"action='{self.action}')" + ) async def __call__(self, *args, **kwargs): """Run the action with full hook lifecycle, timing, and error handling.""" @@ -208,9 +219,7 @@ class Command(BaseModel): def confirmation_prompt(self) -> FormattedText: """Generate a styled prompt_toolkit FormattedText confirmation message.""" if self.confirm_message and self.confirm_message != "Are you sure?": - return FormattedText([ - ("class:confirm", self.confirm_message) - ]) + return FormattedText([("class:confirm", self.confirm_message)]) action_name = getattr(self.action, "__name__", None) if isinstance(self.action, BaseAction): @@ -225,7 +234,9 @@ class Command(BaseModel): prompt.append(("class:confirm", f"(calls `{action_name}`) ")) if self.args or self.kwargs: - prompt.append((OneColors.DARK_YELLOW, f"with args={self.args}, kwargs={self.kwargs} ")) + prompt.append( + (OneColors.DARK_YELLOW, f"with args={self.args}, kwargs={self.kwargs} ") + ) return FormattedText(prompt) @@ -248,4 +259,6 @@ class Command(BaseModel): ) else: console.print(f"{label}") - console.print(f"[{OneColors.DARK_RED}]⚠️ Action is not callable or lacks a preview method.[/]") + console.print( + f"[{OneColors.DARK_RED}]⚠️ Action is not callable or lacks a preview method.[/]" + ) diff --git a/falyx/config.py b/falyx/config.py index 66dee57..5884319 100644 --- a/falyx/config.py +++ b/falyx/config.py @@ -69,7 +69,6 @@ def loader(file_path: str) -> list[dict[str, Any]]: if not isinstance(raw_config, list): raise ValueError("Configuration file must contain a list of command definitions.") - required = ["key", "description", "action"] commands = [] for entry in raw_config: @@ -80,8 +79,9 @@ def loader(file_path: str) -> list[dict[str, Any]]: command_dict = { "key": entry["key"], "description": entry["description"], - "action": wrap_if_needed(import_action(entry["action"]), - name=entry["description"]), + "action": wrap_if_needed( + import_action(entry["action"]), name=entry["description"] + ), "args": tuple(entry.get("args", ())), "kwargs": entry.get("kwargs", {}), "hidden": entry.get("hidden", False), diff --git a/falyx/context.py b/falyx/context.py index 623bac0..f1be3fc 100644 --- a/falyx/context.py +++ b/falyx/context.py @@ -87,7 +87,11 @@ class ExecutionContext(BaseModel): def to_log_line(self) -> str: """Structured flat-line format for logging and metrics.""" duration_str = f"{self.duration:.3f}s" if self.duration is not None else "n/a" - exception_str = f"{type(self.exception).__name__}: {self.exception}" if self.exception else "None" + exception_str = ( + f"{type(self.exception).__name__}: {self.exception}" + if self.exception + else "None" + ) return ( f"[{self.name}] status={self.status} duration={duration_str} " f"result={repr(self.result)} exception={exception_str}" @@ -95,7 +99,11 @@ class ExecutionContext(BaseModel): def __str__(self) -> str: duration_str = f"{self.duration:.3f}s" if self.duration is not None else "n/a" - result_str = f"Result: {repr(self.result)}" if self.success else f"Exception: {self.exception}" + result_str = ( + f"Result: {repr(self.result)}" + if self.success + else f"Exception: {self.exception}" + ) return ( f"" @@ -153,6 +161,7 @@ class SharedContext(BaseModel): f"Errors: {self.errors}>" ) + if __name__ == "__main__": import asyncio diff --git a/falyx/exceptions.py b/falyx/exceptions.py index 6600d22..f20d358 100644 --- a/falyx/exceptions.py +++ b/falyx/exceptions.py @@ -22,6 +22,6 @@ class NotAFalyxError(FalyxError): class CircuitBreakerOpen(FalyxError): """Exception raised when the circuit breaker is open.""" + class EmptyChainError(FalyxError): """Exception raised when the chain is empty.""" - diff --git a/falyx/execution_registry.py b/falyx/execution_registry.py index 9c9b435..2cc8292 100644 --- a/falyx/execution_registry.py +++ b/falyx/execution_registry.py @@ -53,8 +53,16 @@ class ExecutionRegistry: table.add_column("Result / Exception", overflow="fold") for ctx in cls.get_all(): - start = datetime.fromtimestamp(ctx.start_time).strftime("%H:%M:%S") if ctx.start_time else "n/a" - end = datetime.fromtimestamp(ctx.end_time).strftime("%H:%M:%S") if ctx.end_time else "n/a" + start = ( + datetime.fromtimestamp(ctx.start_time).strftime("%H:%M:%S") + if ctx.start_time + else "n/a" + ) + end = ( + datetime.fromtimestamp(ctx.end_time).strftime("%H:%M:%S") + if ctx.end_time + else "n/a" + ) duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a" if ctx.exception: @@ -74,6 +82,8 @@ class ExecutionRegistry: def get_history_action(cls) -> "Action": """Return an Action that prints the execution summary.""" from falyx.action import Action + async def show_history(): cls.summary() + return Action(name="View Execution History", action=show_history) diff --git a/falyx/falyx.py b/falyx/falyx.py index 8651711..c9638c6 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -42,16 +42,25 @@ from falyx.bottom_bar import BottomBar from falyx.command import Command from falyx.context import ExecutionContext from falyx.debug import log_after, log_before, log_error, log_success -from falyx.exceptions import (CommandAlreadyExistsError, FalyxError, - InvalidActionError, NotAFalyxError) +from falyx.exceptions import ( + CommandAlreadyExistsError, + FalyxError, + InvalidActionError, + NotAFalyxError, +) from falyx.execution_registry import ExecutionRegistry as er from falyx.hook_manager import Hook, HookManager, HookType from falyx.options_manager import OptionsManager from falyx.parsers import get_arg_parsers from falyx.retry import RetryPolicy from falyx.themes.colors import OneColors, get_nord_theme -from falyx.utils import (CaseInsensitiveDict, async_confirm, chunks, - get_program_invocation, logger) +from falyx.utils import ( + CaseInsensitiveDict, + async_confirm, + chunks, + get_program_invocation, + logger, +) from falyx.version import __version__ @@ -59,8 +68,8 @@ class Falyx: """ Main menu controller for Falyx CLI applications. - Falyx orchestrates the full lifecycle of an interactive menu system, - handling user input, command execution, error recovery, and structured + Falyx orchestrates the full lifecycle of an interactive menu system, + handling user input, command execution, error recovery, and structured CLI workflows. Key Features: @@ -101,6 +110,7 @@ class Falyx: build_default_table(): Construct the standard Rich table layout. """ + def __init__( self, title: str | Markdown = "Menu", @@ -117,6 +127,7 @@ class Falyx: always_confirm: bool = False, cli_args: Namespace | None = None, options: OptionsManager | None = None, + render_menu: Callable[["Falyx"], None] | None = None, custom_table: Callable[["Falyx"], Table] | Table | None = None, ) -> None: """Initializes the Falyx object.""" @@ -125,8 +136,12 @@ class Falyx: self.columns: int = columns self.commands: dict[str, Command] = CaseInsensitiveDict() self.exit_command: Command = self._get_exit_command() - self.history_command: Command | None = self._get_history_command() if include_history_command else None - self.help_command: Command | None = self._get_help_command() if include_help_command else None + self.history_command: Command | None = ( + self._get_history_command() if include_history_command else None + ) + self.help_command: Command | None = ( + self._get_help_command() if include_help_command else None + ) self.console: Console = Console(color_system="truecolor", theme=get_nord_theme()) self.welcome_message: str | Markdown | dict[str, Any] = welcome_message self.exit_message: str | Markdown | dict[str, Any] = exit_message @@ -138,15 +153,16 @@ class Falyx: self._never_confirm: bool = never_confirm self._always_confirm: bool = always_confirm self.cli_args: Namespace | None = cli_args + self.render_menu: Callable[["Falyx"], None] | None = render_menu self.custom_table: Callable[["Falyx"], Table] | Table | None = custom_table self.set_options(cli_args, options) self._session: PromptSession | None = None def set_options( - self, - cli_args: Namespace | None, - options: OptionsManager | None = None, - ) -> None: + self, + cli_args: Namespace | None, + options: OptionsManager | None = None, + ) -> None: """Checks if the options are set correctly.""" self.options: OptionsManager = options or OptionsManager() if not cli_args and not options: @@ -155,7 +171,9 @@ class Falyx: if options and not cli_args: raise FalyxError("Options are set, but CLI arguments are not.") - assert isinstance(cli_args, Namespace), "CLI arguments must be a Namespace object." + assert isinstance( + cli_args, Namespace + ), "CLI arguments must be a Namespace object." if options is None: self.options.from_namespace(cli_args, "cli_args") @@ -240,27 +258,27 @@ class Falyx: f"[{command.color}]{command.key}[/]", ", ".join(command.aliases) if command.aliases else "None", help_text, - ", ".join(command.tags) if command.tags else "None" + ", ".join(command.tags) if command.tags else "None", ) table.add_row( f"[{self.exit_command.color}]{self.exit_command.key}[/]", ", ".join(self.exit_command.aliases), - "Exit this menu or program" + "Exit this menu or program", ) if self.history_command: table.add_row( f"[{self.history_command.color}]{self.history_command.key}[/]", ", ".join(self.history_command.aliases), - "History of executed actions" - ) + "History of executed actions", + ) if self.help_command: table.add_row( f"[{self.help_command.color}]{self.help_command.key}[/]", ", ".join(self.help_command.aliases), - "Show this help menu" + "Show this help menu", ) self.console.print(table, justify="center") @@ -274,6 +292,7 @@ class Falyx: action=self._show_help, color=OneColors.LIGHT_YELLOW, ) + def _get_completer(self) -> WordCompleter: """Completer to provide auto-completion for the menu commands.""" keys = [self.exit_command.key] @@ -350,18 +369,22 @@ class Falyx: return self._bottom_bar @bottom_bar.setter - def bottom_bar(self, bottom_bar: BottomBar | str | Callable[[], Any] | None) -> None: + def bottom_bar(self, bottom_bar: BottomBar | str | Callable[[], Any] | None) -> None: """Sets the bottom bar for the menu.""" if bottom_bar is None: - self._bottom_bar: BottomBar | str | Callable[[], Any] = BottomBar(self.columns, self.key_bindings, key_validator=self.is_key_available) + self._bottom_bar: BottomBar | str | Callable[[], Any] = BottomBar( + self.columns, self.key_bindings, key_validator=self.is_key_available + ) elif isinstance(bottom_bar, BottomBar): bottom_bar.key_validator = self.is_key_available bottom_bar.key_bindings = self.key_bindings self._bottom_bar = bottom_bar - elif (isinstance(bottom_bar, str) or callable(bottom_bar)): + elif isinstance(bottom_bar, str) or callable(bottom_bar): self._bottom_bar = bottom_bar else: - raise FalyxError("Bottom bar must be a string, callable, or BottomBar instance.") + raise FalyxError( + "Bottom bar must be a string, callable, or BottomBar instance." + ) self._invalidate_session_cache() def _get_bottom_bar_render(self) -> Callable[[], Any] | str | None: @@ -381,14 +404,14 @@ class Falyx: """Returns the prompt session for the menu.""" if self._session is None: self._session = PromptSession( - message=self.prompt, - multiline=False, - completer=self._get_completer(), - reserve_space_for_menu=1, - validator=self._get_validator(), - bottom_toolbar=self._get_bottom_bar_render(), - key_bindings=self.key_bindings, - ) + message=self.prompt, + multiline=False, + completer=self._get_completer(), + reserve_space_for_menu=1, + validator=self._get_validator(), + bottom_toolbar=self._get_bottom_bar_render(), + key_bindings=self.key_bindings, + ) return self._session def register_all_hooks(self, hook_type: HookType, hooks: Hook | list[Hook]) -> None: @@ -414,32 +437,58 @@ class Falyx: def debug_hooks(self) -> None: """Logs the names of all hooks registered for the menu and its commands.""" + def hook_names(hook_list): return [hook.__name__ for hook in hook_list] - logger.debug(f"Menu-level before hooks: {hook_names(self.hooks._hooks[HookType.BEFORE])}") - logger.debug(f"Menu-level success hooks: {hook_names(self.hooks._hooks[HookType.ON_SUCCESS])}") - logger.debug(f"Menu-level error hooks: {hook_names(self.hooks._hooks[HookType.ON_ERROR])}") - logger.debug(f"Menu-level after hooks: {hook_names(self.hooks._hooks[HookType.AFTER])}") - logger.debug(f"Menu-level on_teardown hooks: {hook_names(self.hooks._hooks[HookType.ON_TEARDOWN])}") + logger.debug( + "Menu-level before hooks: " + f"{hook_names(self.hooks._hooks[HookType.BEFORE])}" + ) + logger.debug( + f"Menu-level success hooks: {hook_names(self.hooks._hooks[HookType.ON_SUCCESS])}" + ) + logger.debug( + f"Menu-level error hooks: {hook_names(self.hooks._hooks[HookType.ON_ERROR])}" + ) + logger.debug( + f"Menu-level after hooks: {hook_names(self.hooks._hooks[HookType.AFTER])}" + ) + logger.debug( + f"Menu-level on_teardown hooks: {hook_names(self.hooks._hooks[HookType.ON_TEARDOWN])}" + ) for key, command in self.commands.items(): - logger.debug(f"[Command '{key}'] before: {hook_names(command.hooks._hooks[HookType.BEFORE])}") - logger.debug(f"[Command '{key}'] success: {hook_names(command.hooks._hooks[HookType.ON_SUCCESS])}") - logger.debug(f"[Command '{key}'] error: {hook_names(command.hooks._hooks[HookType.ON_ERROR])}") - logger.debug(f"[Command '{key}'] after: {hook_names(command.hooks._hooks[HookType.AFTER])}") - logger.debug(f"[Command '{key}'] on_teardown: {hook_names(command.hooks._hooks[HookType.ON_TEARDOWN])}") + logger.debug( + f"[Command '{key}'] before: {hook_names(command.hooks._hooks[HookType.BEFORE])}" + ) + logger.debug( + f"[Command '{key}'] success: {hook_names(command.hooks._hooks[HookType.ON_SUCCESS])}" + ) + logger.debug( + f"[Command '{key}'] error: {hook_names(command.hooks._hooks[HookType.ON_ERROR])}" + ) + logger.debug( + f"[Command '{key}'] after: {hook_names(command.hooks._hooks[HookType.AFTER])}" + ) + logger.debug( + f"[Command '{key}'] on_teardown: {hook_names(command.hooks._hooks[HookType.ON_TEARDOWN])}" + ) def is_key_available(self, key: str) -> bool: key = key.upper() - toggles = self._bottom_bar.toggle_keys if isinstance(self._bottom_bar, BottomBar) else [] + toggles = ( + self._bottom_bar.toggle_keys + if isinstance(self._bottom_bar, BottomBar) + else [] + ) conflicts = ( key in self.commands, key == self.exit_command.key.upper(), self.history_command and key == self.history_command.key.upper(), self.help_command and key == self.help_command.key.upper(), - key in toggles + key in toggles, ) return not any(conflicts) @@ -447,7 +496,11 @@ class Falyx: def _validate_command_key(self, key: str) -> None: """Validates the command key to ensure it is unique.""" key = key.upper() - toggles = self._bottom_bar.toggle_keys if isinstance(self._bottom_bar, BottomBar) else [] + toggles = ( + self._bottom_bar.toggle_keys + if isinstance(self._bottom_bar, BottomBar) + else [] + ) collisions = [] if key in self.commands: @@ -462,7 +515,9 @@ class Falyx: collisions.append("toggle") if collisions: - raise CommandAlreadyExistsError(f"Command key '{key}' conflicts with existing {', '.join(collisions)}.") + raise CommandAlreadyExistsError( + f"Command key '{key}' conflicts with existing {', '.join(collisions)}." + ) def update_exit_command( self, @@ -486,7 +541,9 @@ class Falyx: confirm_message=confirm_message, ) - def add_submenu(self, key: str, description: str, submenu: "Falyx", color: str = OneColors.CYAN) -> None: + def add_submenu( + self, key: str, description: str, submenu: "Falyx", color: str = OneColors.CYAN + ) -> None: """Adds a submenu to the menu.""" if not isinstance(submenu, Falyx): raise NotAFalyxError("submenu must be an instance of Falyx.") @@ -581,10 +638,16 @@ class Falyx: """Returns the bottom row of the table for displaying additional commands.""" bottom_row = [] if self.history_command: - bottom_row.append(f"[{self.history_command.key}] [{self.history_command.color}]{self.history_command.description}") + bottom_row.append( + f"[{self.history_command.key}] [{self.history_command.color}]{self.history_command.description}" + ) if self.help_command: - bottom_row.append(f"[{self.help_command.key}] [{self.help_command.color}]{self.help_command.description}") - bottom_row.append(f"[{self.exit_command.key}] [{self.exit_command.color}]{self.exit_command.description}") + bottom_row.append( + f"[{self.help_command.key}] [{self.help_command.color}]{self.help_command.description}" + ) + bottom_row.append( + f"[{self.exit_command.key}] [{self.exit_command.color}]{self.exit_command.description}" + ) return bottom_row def build_default_table(self) -> Table: @@ -626,13 +689,17 @@ class Falyx: fuzzy_matches = get_close_matches(choice, list(name_map.keys()), n=3, cutoff=0.7) if fuzzy_matches: if not from_validate: - self.console.print(f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. Did you mean:[/] ") + self.console.print( + f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. Did you mean:[/] " + ) for match in fuzzy_matches: cmd = name_map[match] self.console.print(f" • [bold]{match}[/] → {cmd.description}") else: if not from_validate: - self.console.print(f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]") + self.console.print( + f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]" + ) return None async def _should_run_action(self, selected_command: Command) -> bool: @@ -642,9 +709,11 @@ class Falyx: if self.cli_args and getattr(self.cli_args, "skip_confirm", False): return True - if (self._always_confirm or - selected_command.confirm or - self.cli_args and getattr(self.cli_args, "force_confirm", False) + if ( + self._always_confirm + or selected_command.confirm + or self.cli_args + and getattr(self.cli_args, "force_confirm", False) ): if selected_command.preview_before_confirm: await selected_command.preview() @@ -676,16 +745,20 @@ class Falyx: ): return await command() - async def _handle_action_error(self, selected_command: Command, error: Exception) -> bool: + async def _handle_action_error( + self, selected_command: Command, error: Exception + ) -> bool: """Handles errors that occur during the action of the selected command.""" logger.exception(f"Error executing '{selected_command.description}': {error}") - self.console.print(f"[{OneColors.DARK_RED}]An error occurred while executing " - f"{selected_command.description}:[/] {error}") + self.console.print( + f"[{OneColors.DARK_RED}]An error occurred while executing " + f"{selected_command.description}:[/] {error}" + ) if self.confirm_on_error and not self._never_confirm: return await async_confirm("An error occurred. Do you wish to continue?") if self._never_confirm: return True - return False + return False async def process_command(self) -> bool: """Processes the action of the selected command.""" @@ -700,8 +773,7 @@ class Falyx: self.console.print( f"[{OneColors.LIGHT_YELLOW}]⚠️ Command '{selected_command.key}' requires input " f"and must be run via [{OneColors.MAGENTA}]'{program} run'[{OneColors.LIGHT_YELLOW}] " - "with proper piping or arguments.[/]" - + "with proper piping or arguments.[/]" ) return True @@ -730,7 +802,9 @@ class Falyx: context.exception = error await self.hooks.trigger(HookType.ON_ERROR, context) if not context.exception: - logger.info(f"✅ Recovery hook handled error for '{selected_command.description}'") + logger.info( + f"✅ Recovery hook handled error for '{selected_command.description}'" + ) context.result = result else: return await self._handle_action_error(selected_command, error) @@ -753,7 +827,9 @@ class Falyx: logger.info(f"[Headless] 🚀 Running: '{selected_command.description}'") if not await self._should_run_action(selected_command): - raise FalyxError(f"[Headless] '{selected_command.description}' cancelled by confirmation.") + raise FalyxError( + f"[Headless] '{selected_command.description}' cancelled by confirmation." + ) context = self._create_context(selected_command) context.start_timer() @@ -769,14 +845,20 @@ class Falyx: await self.hooks.trigger(HookType.ON_SUCCESS, context) logger.info(f"[Headless] ✅ '{selected_command.description}' complete.") except (KeyboardInterrupt, EOFError): - raise FalyxError(f"[Headless] ⚠️ '{selected_command.description}' interrupted by user.") + raise FalyxError( + f"[Headless] ⚠️ '{selected_command.description}' interrupted by user." + ) except Exception as error: context.exception = error await self.hooks.trigger(HookType.ON_ERROR, context) if not context.exception: - logger.info(f"[Headless] ✅ Recovery hook handled error for '{selected_command.description}'") + logger.info( + f"[Headless] ✅ Recovery hook handled error for '{selected_command.description}'" + ) return True - raise FalyxError(f"[Headless] ❌ '{selected_command.description}' failed.") from error + raise FalyxError( + f"[Headless] ❌ '{selected_command.description}' failed." + ) from error finally: context.stop_timer() await self.hooks.trigger(HookType.AFTER, context) @@ -787,7 +869,11 @@ class Falyx: def _set_retry_policy(self, selected_command: Command) -> None: """Sets the retry policy for the command based on CLI arguments.""" assert isinstance(self.cli_args, Namespace), "CLI arguments must be provided." - if self.cli_args.retries or self.cli_args.retry_delay or self.cli_args.retry_backoff: + if ( + self.cli_args.retries + or self.cli_args.retry_delay + or self.cli_args.retry_backoff + ): selected_command.retry_policy.enabled = True if self.cli_args.retries: selected_command.retry_policy.max_retries = self.cli_args.retries @@ -798,7 +884,9 @@ class Falyx: if isinstance(selected_command.action, Action): selected_command.action.set_retry_policy(selected_command.retry_policy) else: - logger.warning(f"[Command:{selected_command.key}] Retry requested, but action is not an Action instance.") + logger.warning( + f"[Command:{selected_command.key}] Retry requested, but action is not an Action instance." + ) def print_message(self, message: str | Markdown | dict[str, Any]) -> None: """Prints a message to the console.""" @@ -821,7 +909,10 @@ class Falyx: if self.welcome_message: self.print_message(self.welcome_message) while True: - self.console.print(self.table, justify="center") + if callable(self.render_menu): + self.render_menu(self) + elif isinstance(self.render_menu, str): + self.console.print(self.table, justify="center") try: task = asyncio.create_task(self.process_command()) should_continue = await task @@ -858,16 +949,22 @@ class Falyx: if self.cli_args.command == "preview": command = self.get_command(self.cli_args.name) if not command: - self.console.print(f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found.[/]") + self.console.print( + f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found.[/]" + ) sys.exit(1) - self.console.print(f"Preview of command '{command.key}': {command.description}") + self.console.print( + f"Preview of command '{command.key}': {command.description}" + ) await command.preview() sys.exit(0) if self.cli_args.command == "run": command = self.get_command(self.cli_args.name) if not command: - self.console.print(f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found.[/]") + self.console.print( + f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found.[/]" + ) sys.exit(1) self._set_retry_policy(command) try: @@ -879,14 +976,19 @@ class Falyx: if self.cli_args.command == "run-all": matching = [ - cmd for cmd in self.commands.values() + cmd + for cmd in self.commands.values() if self.cli_args.tag.lower() in (tag.lower() for tag in cmd.tags) ] if not matching: - self.console.print(f"[{OneColors.LIGHT_YELLOW}]⚠️ No commands found with tag: '{self.cli_args.tag}'[/]") + self.console.print( + f"[{OneColors.LIGHT_YELLOW}]⚠️ No commands found with tag: '{self.cli_args.tag}'[/]" + ) sys.exit(1) - self.console.print(f"[{OneColors.CYAN_b}]🚀 Running all commands with tag:[/] {self.cli_args.tag}") + self.console.print( + f"[{OneColors.CYAN_b}]🚀 Running all commands with tag:[/] {self.cli_args.tag}" + ) for cmd in matching: self._set_retry_policy(cmd) await self.headless(cmd.key) diff --git a/falyx/hook_manager.py b/falyx/hook_manager.py index c7701f4..2fe9bfd 100644 --- a/falyx/hook_manager.py +++ b/falyx/hook_manager.py @@ -10,13 +10,13 @@ from falyx.context import ExecutionContext from falyx.utils import logger Hook = Union[ - Callable[[ExecutionContext], None], - Callable[[ExecutionContext], Awaitable[None]] + Callable[[ExecutionContext], None], Callable[[ExecutionContext], Awaitable[None]] ] class HookType(Enum): """Enum for hook types to categorize the hooks.""" + BEFORE = "before" ON_SUCCESS = "on_success" ON_ERROR = "on_error" @@ -61,10 +61,13 @@ class HookManager: else: hook(context) except Exception as hook_error: - logger.warning(f"⚠️ Hook '{hook.__name__}' raised an exception during '{hook_type}'" - f" for '{context.name}': {hook_error}") + logger.warning( + f"⚠️ Hook '{hook.__name__}' raised an exception during '{hook_type}'" + f" for '{context.name}': {hook_error}" + ) if hook_type == HookType.ON_ERROR: - assert isinstance(context.exception, Exception), "Context exception should be set for ON_ERROR hook" + assert isinstance( + context.exception, Exception + ), "Context exception should be set for ON_ERROR hook" raise context.exception from hook_error - diff --git a/falyx/hooks.py b/falyx/hooks.py index 4b61e28..fbeeb39 100644 --- a/falyx/hooks.py +++ b/falyx/hooks.py @@ -25,9 +25,13 @@ class ResultReporter: raise TypeError("formatter must be callable") if context.result is not None: result_text = self.formatter(context.result) - duration = f"{context.duration:.3f}s" if context.duration is not None else "n/a" - context.console.print(f"[{OneColors.GREEN}]✅ '{context.name}' " - f"completed:[/] {result_text} in {duration}.") + duration = ( + f"{context.duration:.3f}s" if context.duration is not None else "n/a" + ) + context.console.print( + f"[{OneColors.GREEN}]✅ '{context.name}' " + f"completed:[/] {result_text} in {duration}." + ) class CircuitBreaker: @@ -41,7 +45,9 @@ class CircuitBreaker: name = context.name if self.open_until: if time.time() < self.open_until: - raise CircuitBreakerOpen(f"🔴 Circuit open for '{name}' until {time.ctime(self.open_until)}.") + raise CircuitBreakerOpen( + f"🔴 Circuit open for '{name}' until {time.ctime(self.open_until)}." + ) else: logger.info(f"🟢 Circuit closed again for '{name}'.") self.failures = 0 @@ -50,10 +56,14 @@ class CircuitBreaker: def error_hook(self, context: ExecutionContext): name = context.name self.failures += 1 - logger.warning(f"⚠️ CircuitBreaker: '{name}' failure {self.failures}/{self.max_failures}.") + logger.warning( + f"⚠️ CircuitBreaker: '{name}' failure {self.failures}/{self.max_failures}." + ) if self.failures >= self.max_failures: self.open_until = time.time() + self.reset_timeout - logger.error(f"🔴 Circuit opened for '{name}' until {time.ctime(self.open_until)}.") + logger.error( + f"🔴 Circuit opened for '{name}' until {time.ctime(self.open_until)}." + ) def after_hook(self, context: ExecutionContext): self.failures = 0 diff --git a/falyx/http_action.py b/falyx/http_action.py index 7db58ab..f4dde1f 100644 --- a/falyx/http_action.py +++ b/falyx/http_action.py @@ -59,6 +59,7 @@ class HTTPAction(Action): retry (bool): Enable retry logic. retry_policy (RetryPolicy): Retry settings. """ + def __init__( self, name: str, diff --git a/falyx/io_action.py b/falyx/io_action.py index 312f9d2..d05dfe8 100644 --- a/falyx/io_action.py +++ b/falyx/io_action.py @@ -2,7 +2,7 @@ """io_action.py BaseIOAction: A base class for stream- or buffer-based IO-driven Actions. -This module defines `BaseIOAction`, a specialized variant of `BaseAction` +This module defines `BaseIOAction`, a specialized variant of `BaseAction` that interacts with standard input and output, enabling command-line pipelines, text filters, and stream processing tasks. @@ -58,6 +58,7 @@ class BaseIOAction(BaseAction): mode (str): Either "buffered" or "stream". Controls input behavior. inject_last_result (bool): Whether to inject shared context input. """ + def __init__( self, name: str, @@ -94,7 +95,9 @@ class BaseIOAction(BaseAction): if self.inject_last_result and self.shared_context: return self.shared_context.last_result() - logger.debug("[%s] No input provided and no last result found for injection.", self.name) + logger.debug( + "[%s] No input provided and no last result found for injection.", self.name + ) raise FalyxError("No input provided and no last result to inject.") async def __call__(self, *args, **kwargs): @@ -137,7 +140,6 @@ class BaseIOAction(BaseAction): return await asyncio.to_thread(sys.stdin.read) return "" - async def _read_stdin_stream(self) -> Any: """Returns a generator that yields lines from stdin in a background thread.""" loop = asyncio.get_running_loop() @@ -176,7 +178,9 @@ class BaseIOAction(BaseAction): class UppercaseIO(BaseIOAction): def from_input(self, raw: str | bytes) -> str: if not isinstance(raw, (str, bytes)): - raise TypeError(f"{self.name} expected str or bytes input, got {type(raw).__name__}") + raise TypeError( + f"{self.name} expected str or bytes input, got {type(raw).__name__}" + ) return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip() async def _run(self, parsed_input: str, *args, **kwargs) -> str: @@ -213,21 +217,22 @@ class ShellAction(BaseIOAction): command_template (str): Shell command to execute. Must include `{}` to include input. If no placeholder is present, the input is not included. """ + def __init__(self, name: str, command_template: str, **kwargs): super().__init__(name=name, **kwargs) self.command_template = command_template def from_input(self, raw: str | bytes) -> str: if not isinstance(raw, (str, bytes)): - raise TypeError(f"{self.name} expected str or bytes input, got {type(raw).__name__}") + raise TypeError( + f"{self.name} expected str or bytes input, got {type(raw).__name__}" + ) return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip() async def _run(self, parsed_input: str) -> str: # Replace placeholder in template, or use raw input as full command command = self.command_template.format(parsed_input) - result = subprocess.run( - command, shell=True, text=True, capture_output=True - ) + result = subprocess.run(command, shell=True, text=True, capture_output=True) if result.returncode != 0: raise RuntimeError(result.stderr.strip()) return result.stdout.strip() @@ -245,7 +250,10 @@ class ShellAction(BaseIOAction): console.print(Tree("".join(label))) def __str__(self): - return f"ShellAction(name={self.name!r}, command_template={self.command_template!r})" + return ( + f"ShellAction(name={self.name!r}, command_template={self.command_template!r})" + ) + class GrepAction(BaseIOAction): def __init__(self, name: str, pattern: str, **kwargs): @@ -254,13 +262,19 @@ class GrepAction(BaseIOAction): def from_input(self, raw: str | bytes) -> str: if not isinstance(raw, (str, bytes)): - raise TypeError(f"{self.name} expected str or bytes input, got {type(raw).__name__}") + raise TypeError( + f"{self.name} expected str or bytes input, got {type(raw).__name__}" + ) return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip() async def _run(self, parsed_input: str) -> str: command = ["grep", "-n", self.pattern] process = subprocess.Popen( - command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, ) stdout, stderr = process.communicate(input=parsed_input) if process.returncode == 1: @@ -271,4 +285,3 @@ class GrepAction(BaseIOAction): def to_output(self, result: str) -> str: return result - diff --git a/falyx/options_manager.py b/falyx/options_manager.py index 882e280..4fdea69 100644 --- a/falyx/options_manager.py +++ b/falyx/options_manager.py @@ -26,9 +26,7 @@ class OptionsManager: """Get the value of an option.""" return getattr(self.options[namespace_name], option_name, default) - def set( - self, option_name: str, value: Any, namespace_name: str = "cli_args" - ) -> None: + def set(self, option_name: str, value: Any, namespace_name: str = "cli_args") -> None: """Set the value of an option.""" setattr(self.options[namespace_name], option_name, value) diff --git a/falyx/parsers.py b/falyx/parsers.py index 032c207..6d74485 100644 --- a/falyx/parsers.py +++ b/falyx/parsers.py @@ -10,6 +10,7 @@ from typing import Any, Sequence @dataclass class FalyxParsers: """Defines the argument parsers for the Falyx CLI.""" + root: ArgumentParser run: ArgumentParser run_all: ArgumentParser @@ -31,20 +32,20 @@ class FalyxParsers: def get_arg_parsers( - prog: str |None = "falyx", - usage: str | None = None, - description: str | None = "Falyx CLI - Run structured async command workflows.", - epilog: str | None = None, - parents: Sequence[ArgumentParser] = [], - formatter_class: HelpFormatter = HelpFormatter, - prefix_chars: str = "-", - fromfile_prefix_chars: str | None = None, - argument_default: Any = None, - conflict_handler: str = "error", - add_help: bool = True, - allow_abbrev: bool = True, - exit_on_error: bool = True, - ) -> FalyxParsers: + prog: str | None = "falyx", + usage: str | None = None, + description: str | None = "Falyx CLI - Run structured async command workflows.", + epilog: str | None = None, + parents: Sequence[ArgumentParser] = [], + formatter_class: HelpFormatter = HelpFormatter, + prefix_chars: str = "-", + fromfile_prefix_chars: str | None = None, + argument_default: Any = None, + conflict_handler: str = "error", + add_help: bool = True, + allow_abbrev: bool = True, + exit_on_error: bool = True, +) -> FalyxParsers: """Returns the argument parser for the CLI.""" parser = ArgumentParser( prog=prog, @@ -61,33 +62,87 @@ def get_arg_parsers( allow_abbrev=allow_abbrev, exit_on_error=exit_on_error, ) - parser.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging for Falyx.") - parser.add_argument("--debug-hooks", action="store_true", help="Enable default lifecycle debug logging") + parser.add_argument( + "-v", "--verbose", action="store_true", help="Enable debug logging for Falyx." + ) + parser.add_argument( + "--debug-hooks", + action="store_true", + help="Enable default lifecycle debug logging", + ) parser.add_argument("--version", action="store_true", help="Show Falyx version") subparsers = parser.add_subparsers(dest="command") run_parser = subparsers.add_parser("run", help="Run a specific command") run_parser.add_argument("name", help="Key, alias, or description of the command") - run_parser.add_argument("--retries", type=int, help="Number of retries on failure", default=0) - run_parser.add_argument("--retry-delay", type=float, help="Initial delay between retries in (seconds)", default=0) - run_parser.add_argument("--retry-backoff", type=float, help="Backoff factor for retries", default=0) + run_parser.add_argument( + "--retries", type=int, help="Number of retries on failure", default=0 + ) + run_parser.add_argument( + "--retry-delay", + type=float, + help="Initial delay between retries in (seconds)", + default=0, + ) + run_parser.add_argument( + "--retry-backoff", type=float, help="Backoff factor for retries", default=0 + ) run_group = run_parser.add_mutually_exclusive_group(required=False) - run_group.add_argument("-c", "--confirm", dest="force_confirm", action="store_true", help="Force confirmation prompts") - run_group.add_argument("-s", "--skip-confirm", dest="skip_confirm", action="store_true", help="Skip confirmation prompts") + run_group.add_argument( + "-c", + "--confirm", + dest="force_confirm", + action="store_true", + help="Force confirmation prompts", + ) + run_group.add_argument( + "-s", + "--skip-confirm", + dest="skip_confirm", + action="store_true", + help="Skip confirmation prompts", + ) - run_all_parser = subparsers.add_parser("run-all", help="Run all commands with a given tag") + run_all_parser = subparsers.add_parser( + "run-all", help="Run all commands with a given tag" + ) run_all_parser.add_argument("-t", "--tag", required=True, help="Tag to match") - run_all_parser.add_argument("--retries", type=int, help="Number of retries on failure", default=0) - run_all_parser.add_argument("--retry-delay", type=float, help="Initial delay between retries in (seconds)", default=0) - run_all_parser.add_argument("--retry-backoff", type=float, help="Backoff factor for retries", default=0) + run_all_parser.add_argument( + "--retries", type=int, help="Number of retries on failure", default=0 + ) + run_all_parser.add_argument( + "--retry-delay", + type=float, + help="Initial delay between retries in (seconds)", + default=0, + ) + run_all_parser.add_argument( + "--retry-backoff", type=float, help="Backoff factor for retries", default=0 + ) run_all_group = run_all_parser.add_mutually_exclusive_group(required=False) - run_all_group.add_argument("-c", "--confirm", dest="force_confirm", action="store_true", help="Force confirmation prompts") - run_all_group.add_argument("-s", "--skip-confirm", dest="skip_confirm", action="store_true", help="Skip confirmation prompts") + run_all_group.add_argument( + "-c", + "--confirm", + dest="force_confirm", + action="store_true", + help="Force confirmation prompts", + ) + run_all_group.add_argument( + "-s", + "--skip-confirm", + dest="skip_confirm", + action="store_true", + help="Skip confirmation prompts", + ) - preview_parser = subparsers.add_parser("preview", help="Preview a command without running it") + preview_parser = subparsers.add_parser( + "preview", help="Preview a command without running it" + ) preview_parser.add_argument("name", help="Key, alias, or description of the command") - list_parser = subparsers.add_parser("list", help="List all available commands with tags") + list_parser = subparsers.add_parser( + "list", help="List all available commands with tags" + ) version_parser = subparsers.add_parser("version", help="Show the Falyx version") diff --git a/falyx/retry.py b/falyx/retry.py index 2208e10..d2279fc 100644 --- a/falyx/retry.py +++ b/falyx/retry.py @@ -34,15 +34,15 @@ class RetryPolicy(BaseModel): class RetryHandler: - def __init__(self, policy: RetryPolicy=RetryPolicy()): + def __init__(self, policy: RetryPolicy = RetryPolicy()): self.policy = policy def enable_policy( self, - max_retries: int=3, - delay: float=1.0, - backoff: float=2.0, - jitter: float=0.0, + max_retries: int = 3, + delay: float = 1.0, + backoff: float = 2.0, + jitter: float = 0.0, ): self.policy.enabled = True self.policy.max_retries = max_retries @@ -53,6 +53,7 @@ class RetryHandler: async def retry_on_error(self, context: ExecutionContext): from falyx.action import Action + name = context.name error = context.exception target = context.action @@ -66,7 +67,9 @@ class RetryHandler: return if not isinstance(target, Action): - logger.warning(f"[{name}] ❌ RetryHandler only supports only supports Action objects.") + logger.warning( + f"[{name}] ❌ RetryHandler only supports only supports Action objects." + ) return if not getattr(target, "is_retryable", False): diff --git a/falyx/themes/colors.py b/falyx/themes/colors.py index acaf26c..ae02d1e 100644 --- a/falyx/themes/colors.py +++ b/falyx/themes/colors.py @@ -17,6 +17,7 @@ Example dynamic usage: console.print("Hello!", style=NordColors.NORD12bu) # => Renders "Hello!" in #D08770 (Nord12) plus bold and underline styles """ + import re from difflib import get_close_matches @@ -82,14 +83,17 @@ class ColorsMeta(type): except AttributeError: error_msg = [f"'{cls.__name__}' has no color named '{base}'."] valid_bases = [ - key for key, val in cls.__dict__.items() if isinstance(val, str) and - not key.startswith("__") + key + for key, val in cls.__dict__.items() + if isinstance(val, str) and not key.startswith("__") ] suggestions = get_close_matches(base, valid_bases, n=1, cutoff=0.5) if suggestions: error_msg.append(f"Did you mean '{suggestions[0]}'?") if valid_bases: - error_msg.append(f"Valid base color names include: {', '.join(valid_bases)}") + error_msg.append( + f"Valid base color names include: {', '.join(valid_bases)}" + ) raise AttributeError(" ".join(error_msg)) from None if not isinstance(color_value, str): @@ -105,7 +109,9 @@ class ColorsMeta(type): if mapped_style: styles.append(mapped_style) else: - raise AttributeError(f"Unknown style flag '{letter}' in attribute '{name}'") + raise AttributeError( + f"Unknown style flag '{letter}' in attribute '{name}'" + ) order = {"b": 1, "i": 2, "u": 3, "d": 4, "r": 5, "s": 6} styles_sorted = sorted(styles, key=lambda s: order[s[0]]) @@ -133,7 +139,6 @@ class OneColors(metaclass=ColorsMeta): BLUE = "#61AFEF" MAGENTA = "#C678DD" - @classmethod def as_dict(cls): """ @@ -143,10 +148,10 @@ class OneColors(metaclass=ColorsMeta): return { attr: getattr(cls, attr) for attr in dir(cls) - if not callable(getattr(cls, attr)) and - not attr.startswith("__") + if not callable(getattr(cls, attr)) and not attr.startswith("__") } + class NordColors(metaclass=ColorsMeta): """ Defines the Nord color palette as class attributes. @@ -215,19 +220,19 @@ class NordColors(metaclass=ColorsMeta): return { attr: getattr(cls, attr) for attr in dir(cls) - if attr.startswith("NORD") and - not callable(getattr(cls, attr)) + if attr.startswith("NORD") and not callable(getattr(cls, attr)) } @classmethod def aliases(cls): """ - Returns a dictionary of *all* other aliases + Returns a dictionary of *all* other aliases (Polar Night, Snow Storm, Frost, Aurora). """ skip_prefixes = ("NORD", "__") alias_names = [ - attr for attr in dir(cls) + attr + for attr in dir(cls) if not any(attr.startswith(sp) for sp in skip_prefixes) and not callable(getattr(cls, attr)) ] @@ -264,7 +269,6 @@ NORD_THEME_STYLES: dict[str, Style] = { "blink2": Style(blink2=True), "reverse": Style(reverse=True), "strike": Style(strike=True), - # --------------------------------------------------------------- # Basic color names mapped to Nord # --------------------------------------------------------------- @@ -277,7 +281,6 @@ NORD_THEME_STYLES: dict[str, Style] = { "cyan": Style(color=NordColors.CYAN), "blue": Style(color=NordColors.BLUE), "white": Style(color=NordColors.SNOW_STORM_BRIGHTEST), - # --------------------------------------------------------------- # Inspect # --------------------------------------------------------------- @@ -292,14 +295,12 @@ NORD_THEME_STYLES: dict[str, Style] = { "inspect.help": Style(color=NordColors.FROST_ICE), "inspect.doc": Style(dim=True), "inspect.value.border": Style(color=NordColors.GREEN), - # --------------------------------------------------------------- # Live / Layout # --------------------------------------------------------------- "live.ellipsis": Style(bold=True, color=NordColors.RED), "layout.tree.row": Style(dim=False, color=NordColors.RED), "layout.tree.column": Style(dim=False, color=NordColors.FROST_DEEP), - # --------------------------------------------------------------- # Logging # --------------------------------------------------------------- @@ -314,7 +315,6 @@ NORD_THEME_STYLES: dict[str, Style] = { "log.time": Style(color=NordColors.FROST_ICE, dim=True), "log.message": Style.null(), "log.path": Style(dim=True), - # --------------------------------------------------------------- # Python repr # --------------------------------------------------------------- @@ -340,18 +340,18 @@ NORD_THEME_STYLES: dict[str, Style] = { "repr.bool_true": Style(color=NordColors.GREEN, italic=True), "repr.bool_false": Style(color=NordColors.RED, italic=True), "repr.none": Style(color=NordColors.PURPLE, italic=True), - "repr.url": Style(underline=True, color=NordColors.FROST_ICE, italic=False, bold=False), + "repr.url": Style( + underline=True, color=NordColors.FROST_ICE, italic=False, bold=False + ), "repr.uuid": Style(color=NordColors.YELLOW, bold=False), "repr.call": Style(color=NordColors.PURPLE, bold=True), "repr.path": Style(color=NordColors.PURPLE), "repr.filename": Style(color=NordColors.PURPLE), - # --------------------------------------------------------------- # Rule # --------------------------------------------------------------- "rule.line": Style(color=NordColors.GREEN), "rule.text": Style.null(), - # --------------------------------------------------------------- # JSON # --------------------------------------------------------------- @@ -362,7 +362,6 @@ NORD_THEME_STYLES: dict[str, Style] = { "json.number": Style(color=NordColors.FROST_ICE, bold=True, italic=False), "json.str": Style(color=NordColors.GREEN, italic=False, bold=False), "json.key": Style(color=NordColors.FROST_ICE, bold=True), - # --------------------------------------------------------------- # Prompt # --------------------------------------------------------------- @@ -371,12 +370,10 @@ NORD_THEME_STYLES: dict[str, Style] = { "prompt.default": Style(color=NordColors.FROST_ICE, bold=True), "prompt.invalid": Style(color=NordColors.RED), "prompt.invalid.choice": Style(color=NordColors.RED), - # --------------------------------------------------------------- # Pretty # --------------------------------------------------------------- "pretty": Style.null(), - # --------------------------------------------------------------- # Scope # --------------------------------------------------------------- @@ -384,7 +381,6 @@ NORD_THEME_STYLES: dict[str, Style] = { "scope.key": Style(color=NordColors.YELLOW, italic=True), "scope.key.special": Style(color=NordColors.YELLOW, italic=True, dim=True), "scope.equals": Style(color=NordColors.RED), - # --------------------------------------------------------------- # Table # --------------------------------------------------------------- @@ -393,7 +389,6 @@ NORD_THEME_STYLES: dict[str, Style] = { "table.cell": Style.null(), "table.title": Style(italic=True), "table.caption": Style(italic=True, dim=True), - # --------------------------------------------------------------- # Traceback # --------------------------------------------------------------- @@ -405,7 +400,6 @@ NORD_THEME_STYLES: dict[str, Style] = { "traceback.exc_type": Style(color=NordColors.RED, bold=True), "traceback.exc_value": Style.null(), "traceback.offset": Style(color=NordColors.RED, bold=True), - # --------------------------------------------------------------- # Progress bars # --------------------------------------------------------------- @@ -423,13 +417,11 @@ NORD_THEME_STYLES: dict[str, Style] = { "progress.data.speed": Style(color=NordColors.RED), "progress.spinner": Style(color=NordColors.GREEN), "status.spinner": Style(color=NordColors.GREEN), - # --------------------------------------------------------------- # Tree # --------------------------------------------------------------- "tree": Style(), "tree.line": Style(), - # --------------------------------------------------------------- # Markdown # --------------------------------------------------------------- @@ -438,8 +430,12 @@ NORD_THEME_STYLES: dict[str, Style] = { "markdown.em": Style(italic=True), "markdown.emph": Style(italic=True), # For commonmark compatibility "markdown.strong": Style(bold=True), - "markdown.code": Style(bold=True, color=NordColors.FROST_ICE, bgcolor=NordColors.POLAR_NIGHT_ORIGIN), - "markdown.code_block": Style(color=NordColors.FROST_ICE, bgcolor=NordColors.POLAR_NIGHT_ORIGIN), + "markdown.code": Style( + bold=True, color=NordColors.FROST_ICE, bgcolor=NordColors.POLAR_NIGHT_ORIGIN + ), + "markdown.code_block": Style( + color=NordColors.FROST_ICE, bgcolor=NordColors.POLAR_NIGHT_ORIGIN + ), "markdown.block_quote": Style(color=NordColors.PURPLE), "markdown.list": Style(color=NordColors.FROST_ICE), "markdown.item": Style(), @@ -457,7 +453,6 @@ NORD_THEME_STYLES: dict[str, Style] = { "markdown.link": Style(color=NordColors.FROST_ICE), "markdown.link_url": Style(color=NordColors.FROST_SKY, underline=True), "markdown.s": Style(strike=True), - # --------------------------------------------------------------- # ISO8601 # --------------------------------------------------------------- @@ -504,7 +499,9 @@ if __name__ == "__main__": console.print(f"Caught error: {error}", style="red") # Demonstrate a traceback style: - console.print("\n8) Raising and displaying a traceback with Nord styling:\n", style="bold") + console.print( + "\n8) Raising and displaying a traceback with Nord styling:\n", style="bold" + ) try: raise ValueError("Nord test exception!") except ValueError: diff --git a/falyx/utils.py b/falyx/utils.py index a3ff4ed..b9a8619 100644 --- a/falyx/utils.py +++ b/falyx/utils.py @@ -11,8 +11,11 @@ from typing import Any, Awaitable, Callable, TypeVar import pythonjsonlogger.json from prompt_toolkit import PromptSession -from prompt_toolkit.formatted_text import (AnyFormattedText, FormattedText, - merge_formatted_text) +from prompt_toolkit.formatted_text import ( + AnyFormattedText, + FormattedText, + merge_formatted_text, +) from rich.logging import RichHandler from falyx.themes.colors import OneColors @@ -21,6 +24,7 @@ logger = logging.getLogger("falyx") T = TypeVar("T") + async def _noop(*args, **kwargs): pass @@ -44,7 +48,7 @@ def is_coroutine(function: Callable[..., Any]) -> bool: def ensure_async(function: Callable[..., T]) -> Callable[..., Awaitable[T]]: if is_coroutine(function): - return function # type: ignore + return function # type: ignore @functools.wraps(function) async def async_wrapper(*args, **kwargs) -> T: @@ -68,7 +72,9 @@ def chunks(iterator, size): async def async_confirm(message: AnyFormattedText = "Are you sure?") -> bool: session: PromptSession = PromptSession() while True: - merged_message: AnyFormattedText = merge_formatted_text([message, FormattedText([(OneColors.LIGHT_YELLOW_b, " [Y/n] ")])]) + merged_message: AnyFormattedText = merge_formatted_text( + [message, FormattedText([(OneColors.LIGHT_YELLOW_b, " [Y/n] ")])] + ) answer: str = (await session.prompt_async(merged_message)).strip().lower() if answer in ("y", "yes"): return True @@ -182,7 +188,9 @@ def setup_logging( elif mode == "json": console_handler = logging.StreamHandler() console_handler.setFormatter( - pythonjsonlogger.json.JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s") + pythonjsonlogger.json.JsonFormatter( + "%(asctime)s %(name)s %(levelname)s %(message)s" + ) ) else: raise ValueError(f"Invalid log mode: {mode}") @@ -194,13 +202,17 @@ def setup_logging( file_handler.setLevel(file_log_level) if json_log_to_file: file_handler.setFormatter( - pythonjsonlogger.json.JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s") + pythonjsonlogger.json.JsonFormatter( + "%(asctime)s %(name)s %(levelname)s %(message)s" + ) ) else: - file_handler.setFormatter(logging.Formatter( - "%(asctime)s [%(name)s] [%(levelname)s] %(message)s", - datefmt="%Y-%m-%d %H:%M:%S" - )) + file_handler.setFormatter( + logging.Formatter( + "%(asctime)s [%(name)s] [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) root.addHandler(file_handler) logging.getLogger("urllib3").setLevel(logging.WARNING) diff --git a/falyx/version.py b/falyx/version.py index 0a8da88..f1380ee 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.6" +__version__ = "0.1.7" diff --git a/pyproject.toml b/pyproject.toml index b3abb44..227c54c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "falyx" -version = "0.1.6" +version = "0.1.7" description = "Reliable and introspectable async CLI action framework." authors = ["Roland Thomas Jr "] license = "MIT" @@ -18,6 +18,12 @@ python-json-logger = "^3.3.0" pytest = "^7.0" pytest-asyncio = "^0.20" ruff = "^0.3" +toml = "^0.10" +black = { version = "^25.0", allow-prereleases = true } +mypy = { version = "^1.0", allow-prereleases = true } +isort = { version = "^5.0", allow-prereleases = true } +pytest-cov = "^4.0" +pytest-mock = "^3.0" [tool.poetry.scripts] sync-version = "scripts.sync_version:main" diff --git a/scripts/sync_version.py b/scripts/sync_version.py index 1b9ef24..84989f6 100644 --- a/scripts/sync_version.py +++ b/scripts/sync_version.py @@ -1,8 +1,10 @@ """scripts/sync_version.py""" -import toml from pathlib import Path +import toml + + def main(): pyproject_path = Path(__file__).parent.parent / "pyproject.toml" version_path = Path(__file__).parent.parent / "falyx" / "version.py" @@ -13,5 +15,6 @@ def main(): version_path.write_text(f'__version__ = "{version}"\n') print(f"✅ Synced version: {version} → {version_path}") + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/test_action_basic.py b/tests/test_action_basic.py index 8a3ba34..2f4ff14 100644 --- a/tests/test_action_basic.py +++ b/tests/test_action_basic.py @@ -1,15 +1,17 @@ import pytest -from falyx.action import Action, ChainedAction, LiteralInputAction, FallbackAction -from falyx.execution_registry import ExecutionRegistry as er +from falyx.action import Action, ChainedAction, FallbackAction, LiteralInputAction from falyx.context import ExecutionContext +from falyx.execution_registry import ExecutionRegistry as er asyncio_default_fixture_loop_scope = "function" + # --- Helpers --- async def capturing_hook(context: ExecutionContext): context.extra["hook_triggered"] = True + # --- Fixtures --- @pytest.fixture(autouse=True) def clean_registry(): @@ -18,7 +20,6 @@ def clean_registry(): er.clear() - @pytest.mark.asyncio async def test_action_callable(): """Test if Action can be created with a callable.""" @@ -26,15 +27,22 @@ async def test_action_callable(): result = await action() assert result == "Hello, World!" + @pytest.mark.asyncio async def test_action_async_callable(): """Test if Action can be created with an async callable.""" + async def async_callable(): return "Hello, World!" + action = Action("test_action", async_callable) result = await action() assert result == "Hello, World!" - assert str(action) == "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)" + assert ( + str(action) + == "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)" + ) + @pytest.mark.asyncio async def test_action_non_callable(): @@ -42,11 +50,15 @@ async def test_action_non_callable(): with pytest.raises(TypeError): Action("test_action", 42) + @pytest.mark.asyncio -@pytest.mark.parametrize("return_list, expected", [ - (True, [1, 2, 3]), - (False, 3), -]) +@pytest.mark.parametrize( + "return_list, expected", + [ + (True, [1, 2, 3]), + (False, 3), + ], +) async def test_chained_action_return_modes(return_list, expected): chain = ChainedAction( name="Simple Chain", @@ -55,19 +67,23 @@ async def test_chained_action_return_modes(return_list, expected): Action(name="two", action=lambda: 2), Action(name="three", action=lambda: 3), ], - return_list=return_list + return_list=return_list, ) result = await chain() assert result == expected + @pytest.mark.asyncio -@pytest.mark.parametrize("return_list, auto_inject, expected", [ - (True, True, [1, 2, 3]), - (True, False, [1, 2, 3]), - (False, True, 3), - (False, False, 3), -]) +@pytest.mark.parametrize( + "return_list, auto_inject, expected", + [ + (True, True, [1, 2, 3]), + (True, False, [1, 2, 3]), + (False, True, 3), + (False, False, 3), + ], +) async def test_chained_action_literals(return_list, auto_inject, expected): chain = ChainedAction( name="Literal Chain", @@ -79,6 +95,7 @@ async def test_chained_action_literals(return_list, auto_inject, expected): result = await chain() assert result == expected + @pytest.mark.asyncio async def test_literal_input_action(): """Test if LiteralInputAction can be created and used.""" @@ -88,6 +105,7 @@ async def test_literal_input_action(): assert action.value == "Hello, World!" assert str(action) == "LiteralInputAction(value='Hello, World!')" + @pytest.mark.asyncio async def test_fallback_action(): """Test if FallbackAction can be created and used.""" @@ -102,4 +120,3 @@ async def test_fallback_action(): result = await chain() assert result == "Fallback value" assert str(action) == "FallbackAction(fallback='Fallback value')" - diff --git a/tests/test_action_process.py b/tests/test_action_process.py index bc6f696..5c3efae 100644 --- a/tests/test_action_process.py +++ b/tests/test_action_process.py @@ -1,5 +1,6 @@ import pickle import warnings + import pytest from falyx.action import ProcessAction @@ -7,17 +8,21 @@ from falyx.execution_registry import ExecutionRegistry as er # --- Fixtures --- + @pytest.fixture(autouse=True) def clean_registry(): er.clear() yield er.clear() + def slow_add(x, y): return x + y + # --- Tests --- + @pytest.mark.asyncio async def test_process_action_executes_correctly(): with warnings.catch_warnings(): @@ -27,8 +32,10 @@ async def test_process_action_executes_correctly(): result = await action() assert result == 5 + unpickleable = lambda x: x + 1 + @pytest.mark.asyncio async def test_process_action_rejects_unpickleable(): with warnings.catch_warnings(): @@ -37,4 +44,3 @@ async def test_process_action_rejects_unpickleable(): action = ProcessAction(name="proc_fail", func=unpickleable, args=(2,)) with pytest.raises(pickle.PicklingError, match="Can't pickle"): await action() - diff --git a/tests/test_action_retries.py b/tests/test_action_retries.py index fd2be9a..371e192 100644 --- a/tests/test_action_retries.py +++ b/tests/test_action_retries.py @@ -6,6 +6,7 @@ from falyx.retry_utils import enable_retries_recursively asyncio_default_fixture_loop_scope = "function" + # --- Fixtures --- @pytest.fixture(autouse=True) def clean_registry(): @@ -13,6 +14,7 @@ def clean_registry(): yield er.clear() + def test_action_enable_retry(): """Test if Action can be created with retry=True.""" action = Action("test_action", lambda: "Hello, World!", retry=True) diff --git a/tests/test_actions.py b/tests/test_actions.py index d7e2ccf..7fe24f2 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -1,16 +1,18 @@ import pytest -from falyx.action import Action, ChainedAction, ActionGroup, FallbackAction +from falyx.action import Action, ActionGroup, ChainedAction, FallbackAction +from falyx.context import ExecutionContext from falyx.execution_registry import ExecutionRegistry as er from falyx.hook_manager import HookManager, HookType -from falyx.context import ExecutionContext asyncio_default_fixture_loop_scope = "function" + # --- Helpers --- async def capturing_hook(context: ExecutionContext): context.extra["hook_triggered"] = True + # --- Fixtures --- @pytest.fixture def hook_manager(): @@ -18,29 +20,33 @@ def hook_manager(): hm.register(HookType.BEFORE, capturing_hook) return hm + @pytest.fixture(autouse=True) def clean_registry(): er.clear() yield er.clear() + # --- Tests --- + @pytest.mark.asyncio async def test_action_runs_correctly(): - async def dummy_action(x: int = 0) -> int: return x + 1 + async def dummy_action(x: int = 0) -> int: + return x + 1 + sample_action = Action(name="increment", action=dummy_action, kwargs={"x": 5}) result = await sample_action() assert result == 6 + @pytest.mark.asyncio async def test_action_hook_lifecycle(hook_manager): - async def a1(): return 42 - action = Action( - name="hooked", - action=a1, - hooks=hook_manager - ) + async def a1(): + return 42 + + action = Action(name="hooked", action=a1, hooks=hook_manager) await action() @@ -48,28 +54,44 @@ async def test_action_hook_lifecycle(hook_manager): assert context.name == "hooked" assert context.extra.get("hook_triggered") is True + @pytest.mark.asyncio async def test_chained_action_with_result_injection(): - async def a1(): return 1 - async def a2(last_result): return last_result + 5 - async def a3(last_result): return last_result * 2 + async def a1(): + return 1 + + async def a2(last_result): + return last_result + 5 + + async def a3(last_result): + return last_result * 2 + actions = [ Action(name="start", action=a1), Action(name="add_last", action=a2, inject_last_result=True), - Action(name="multiply", action=a3, inject_last_result=True) + Action(name="multiply", action=a3, inject_last_result=True), ] - chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True, return_list=True) + chain = ChainedAction( + name="test_chain", actions=actions, inject_last_result=True, return_list=True + ) result = await chain() assert result == [1, 6, 12] chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True) result = await chain() assert result == 12 + @pytest.mark.asyncio async def test_action_group_runs_in_parallel(): - async def a1(): return 1 - async def a2(): return 2 - async def a3(): return 3 + async def a1(): + return 1 + + async def a2(): + return 2 + + async def a3(): + return 3 + actions = [ Action(name="a", action=a1), Action(name="b", action=a2), @@ -80,10 +102,15 @@ async def test_action_group_runs_in_parallel(): result_dict = dict(result) assert result_dict == {"a": 1, "b": 2, "c": 3} + @pytest.mark.asyncio async def test_chained_action_inject_from_action(): - async def a1(last_result): return last_result + 10 - async def a2(last_result): return last_result + 5 + async def a1(last_result): + return last_result + 10 + + async def a2(last_result): + return last_result + 5 + inner_chain = ChainedAction( name="inner_chain", actions=[ @@ -92,8 +119,13 @@ async def test_chained_action_inject_from_action(): ], return_list=True, ) - async def a3(): return 1 - async def a4(last_result): return last_result + 2 + + async def a3(): + return 1 + + async def a4(last_result): + return last_result + 2 + actions = [ Action(name="first", action=a3), Action(name="second", action=a4, inject_last_result=True), @@ -103,21 +135,33 @@ async def test_chained_action_inject_from_action(): result = await outer_chain() assert result == [1, 3, [13, 18]] + @pytest.mark.asyncio async def test_chained_action_with_group(): - async def a1(last_result): return last_result + 1 - async def a2(last_result): return last_result + 2 - async def a3(): return 3 + async def a1(last_result): + return last_result + 1 + + async def a2(last_result): + return last_result + 2 + + async def a3(): + return 3 + group = ActionGroup( name="group", actions=[ Action(name="a", action=a1, inject_last_result=True), Action(name="b", action=a2, inject_last_result=True), Action(name="c", action=a3), - ] + ], ) - async def a4(): return 1 - async def a5(last_result): return last_result + 2 + + async def a4(): + return 1 + + async def a5(last_result): + return last_result + 2 + actions = [ Action(name="first", action=a4), Action(name="second", action=a5, inject_last_result=True), @@ -127,6 +171,7 @@ async def test_chained_action_with_group(): result = await chain() assert result == [1, 3, [("a", 4), ("b", 5), ("c", 3)]] + @pytest.mark.asyncio async def test_action_error_triggers_error_hook(): def fail(): @@ -146,6 +191,7 @@ async def test_action_error_triggers_error_hook(): assert flag.get("called") is True + @pytest.mark.asyncio async def test_chained_action_rollback_on_failure(): rollback_called = [] @@ -161,7 +207,7 @@ async def test_chained_action_rollback_on_failure(): actions = [ Action(name="ok", action=success, rollback=rollback_fn), - Action(name="fail", action=fail, rollback=rollback_fn) + Action(name="fail", action=fail, rollback=rollback_fn), ] chain = ChainedAction(name="chain", actions=actions) @@ -171,13 +217,17 @@ async def test_chained_action_rollback_on_failure(): assert rollback_called == ["rolled back"] + @pytest.mark.asyncio async def test_register_hooks_recursively_propagates(): def hook(context): context.extra.update({"test_marker": True}) - async def a1(): return 1 - async def a2(): return 2 + async def a1(): + return 1 + + async def a2(): + return 2 chain = ChainedAction( name="chain", @@ -193,6 +243,7 @@ async def test_register_hooks_recursively_propagates(): for ctx in er.get_by_name("a") + er.get_by_name("b"): assert ctx.extra.get("test_marker") is True + @pytest.mark.asyncio async def test_action_hook_recovers_error(): async def flaky(): @@ -209,15 +260,26 @@ async def test_action_hook_recovers_error(): result = await action() assert result == 99 + @pytest.mark.asyncio async def test_action_group_injects_last_result(): - async def a1(last_result): return last_result + 10 - async def a2(last_result): return last_result + 20 - group = ActionGroup(name="group", actions=[ - Action(name="g1", action=a1, inject_last_result=True), - Action(name="g2", action=a2, inject_last_result=True), - ]) - async def a3(): return 5 + async def a1(last_result): + return last_result + 10 + + async def a2(last_result): + return last_result + 20 + + group = ActionGroup( + name="group", + actions=[ + Action(name="g1", action=a1, inject_last_result=True), + Action(name="g2", action=a2, inject_last_result=True), + ], + ) + + async def a3(): + return 5 + chain = ChainedAction( name="with_group", actions=[ @@ -230,20 +292,30 @@ async def test_action_group_injects_last_result(): result_dict = dict(result[1]) assert result_dict == {"g1": 15, "g2": 25} + @pytest.mark.asyncio async def test_action_inject_last_result(): - async def a1(): return 1 - async def a2(last_result): return last_result + 1 + async def a1(): + return 1 + + async def a2(last_result): + return last_result + 1 + a1 = Action(name="a1", action=a1) a2 = Action(name="a2", action=a2, inject_last_result=True) chain = ChainedAction(name="chain", actions=[a1, a2]) result = await chain() assert result == 2 + @pytest.mark.asyncio async def test_action_inject_last_result_fail(): - async def a1(): return 1 - async def a2(last_result): return last_result + 1 + async def a1(): + return 1 + + async def a2(last_result): + return last_result + 1 + a1 = Action(name="a1", action=a1) a2 = Action(name="a2", action=a2) chain = ChainedAction(name="chain", actions=[a1, a2]) @@ -253,54 +325,82 @@ async def test_action_inject_last_result_fail(): assert "last_result" in str(exc_info.value) + @pytest.mark.asyncio async def test_chained_action_auto_inject(): - async def a1(): return 1 - async def a2(last_result): return last_result + 2 + async def a1(): + return 1 + + async def a2(last_result): + return last_result + 2 + a1 = Action(name="a1", action=a1) a2 = Action(name="a2", action=a2) - chain = ChainedAction(name="chain", actions=[a1, a2], auto_inject=True, return_list=True) + chain = ChainedAction( + name="chain", actions=[a1, a2], auto_inject=True, return_list=True + ) result = await chain() - assert result == [1, 3] # a2 receives last_result=1 + assert result == [1, 3] # a2 receives last_result=1 + @pytest.mark.asyncio async def test_chained_action_no_auto_inject(): - async def a1(): return 1 - async def a2(): return 2 + async def a1(): + return 1 + + async def a2(): + return 2 + a1 = Action(name="a1", action=a1) a2 = Action(name="a2", action=a2) - chain = ChainedAction(name="no_inject", actions=[a1, a2], auto_inject=False, return_list=True) + chain = ChainedAction( + name="no_inject", actions=[a1, a2], auto_inject=False, return_list=True + ) result = await chain() - assert result == [1, 2] # a2 does not receive 1 + assert result == [1, 2] # a2 does not receive 1 + @pytest.mark.asyncio async def test_chained_action_auto_inject_after_first(): - async def a1(): return 1 - async def a2(last_result): return last_result + 1 + async def a1(): + return 1 + + async def a2(last_result): + return last_result + 1 + a1 = Action(name="a1", action=a1) a2 = Action(name="a2", action=a2) chain = ChainedAction(name="auto_inject", actions=[a1, a2], auto_inject=True) result = await chain() assert result == 2 # a2 receives last_result=1 + @pytest.mark.asyncio async def test_chained_action_with_literal_input(): - async def a1(last_result): return last_result + " world" + async def a1(last_result): + return last_result + " world" + a1 = Action(name="a1", action=a1) chain = ChainedAction(name="literal_inject", actions=["hello", a1], auto_inject=True) result = await chain() assert result == "hello world" # "hello" is injected as last_result + @pytest.mark.asyncio async def test_chained_action_manual_inject_override(): - async def a1(): return 10 - async def a2(last_result): return last_result * 2 + async def a1(): + return 10 + + async def a2(last_result): + return last_result * 2 + a1 = Action(name="a1", action=a1) a2 = Action(name="a2", action=a2, inject_last_result=True) chain = ChainedAction(name="manual_override", actions=[a1, a2], auto_inject=False) result = await chain() assert result == 20 # Even without auto_inject, a2 still gets last_result + @pytest.mark.asyncio async def test_chained_action_with_mid_literal(): async def fetch_data(): @@ -330,6 +430,7 @@ async def test_chained_action_with_mid_literal(): result = await chain() assert result == [None, "default_value", "default_value", "Enriched: default_value"] + @pytest.mark.asyncio async def test_chained_action_with_mid_fallback(): async def fetch_data(): @@ -389,15 +490,22 @@ async def test_chained_action_with_success_mid_fallback(): result = await chain() assert result == ["Result", "Result", "Result", "Enriched: Result"] + @pytest.mark.asyncio async def test_action_group_partial_failure(): - async def succeed(): return "ok" - async def fail(): raise ValueError("oops") + async def succeed(): + return "ok" - group = ActionGroup(name="partial_group", actions=[ - Action(name="succeed_action", action=succeed), - Action(name="fail_action", action=fail), - ]) + async def fail(): + raise ValueError("oops") + + group = ActionGroup( + name="partial_group", + actions=[ + Action(name="succeed_action", action=succeed), + Action(name="fail_action", action=fail), + ], + ) with pytest.raises(Exception) as exc_info: await group() @@ -406,10 +514,15 @@ async def test_action_group_partial_failure(): assert er.get_by_name("fail_action")[0].exception is not None assert "fail_action" in str(exc_info.value) + @pytest.mark.asyncio async def test_chained_action_with_nested_group(): - async def g1(last_result): return last_result + "10" - async def g2(last_result): return last_result + "20" + async def g1(last_result): + return last_result + "10" + + async def g2(last_result): + return last_result + "20" + group = ActionGroup( name="nested_group", actions=[ @@ -431,7 +544,11 @@ async def test_chained_action_with_nested_group(): result = await chain() # "start" -> group both receive "start" as last_result assert result[0] == "start" - assert dict(result[1]) == {"g1": "start10", "g2": "start20"} # Assuming string concatenation for example + assert dict(result[1]) == { + "g1": "start10", + "g2": "start20", + } # Assuming string concatenation for example + @pytest.mark.asyncio async def test_chained_action_double_fallback(): @@ -461,5 +578,11 @@ async def test_chained_action_double_fallback(): ) result = await chain() - assert result == [None, "default1", "default1", None, "default2", "Enriched: default2"] - + assert result == [ + None, + "default1", + "default1", + None, + "default2", + "Enriched: default2", + ] diff --git a/tests/test_chained_action_empty.py b/tests/test_chained_action_empty.py index 81e407a..83f0a82 100644 --- a/tests/test_chained_action_empty.py +++ b/tests/test_chained_action_empty.py @@ -3,6 +3,7 @@ import pytest from falyx.action import ChainedAction from falyx.exceptions import EmptyChainError + @pytest.mark.asyncio async def test_chained_action_raises_empty_chain_error_when_no_actions(): """A ChainedAction with no actions should raise an EmptyChainError immediately.""" @@ -14,6 +15,7 @@ async def test_chained_action_raises_empty_chain_error_when_no_actions(): assert "No actions to execute." in str(exc_info.value) assert "empty_chain" in str(exc_info.value) + @pytest.mark.asyncio async def test_chained_action_raises_empty_chain_error_when_actions_are_none(): """A ChainedAction with None as actions should raise an EmptyChainError immediately.""" @@ -24,4 +26,3 @@ async def test_chained_action_raises_empty_chain_error_when_actions_are_none(): assert "No actions to execute." in str(exc_info.value) assert "none_chain" in str(exc_info.value) - diff --git a/tests/test_command.py b/tests/test_command.py index 9b98b60..dec97dc 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -3,12 +3,13 @@ import pytest from falyx.action import Action, ActionGroup, ChainedAction from falyx.command import Command -from falyx.io_action import BaseIOAction from falyx.execution_registry import ExecutionRegistry as er +from falyx.io_action import BaseIOAction from falyx.retry import RetryPolicy asyncio_default_fixture_loop_scope = "function" + # --- Fixtures --- @pytest.fixture(autouse=True) def clean_registry(): @@ -16,10 +17,12 @@ def clean_registry(): yield er.clear() + # --- Dummy Action --- async def dummy_action(): return "ok" + # --- Dummy IO Action --- class DummyInputAction(BaseIOAction): async def _run(self, *args, **kwargs): @@ -28,46 +31,46 @@ class DummyInputAction(BaseIOAction): async def preview(self, parent=None): pass + # --- Tests --- def test_command_creation(): """Test if Command can be created with a callable.""" action = Action("test_action", dummy_action) - cmd = Command( - key="TEST", - description="Test Command", - action=action - ) + cmd = Command(key="TEST", description="Test Command", action=action) assert cmd.key == "TEST" assert cmd.description == "Test Command" assert cmd.action == action + def test_command_str(): """Test if Command string representation is correct.""" action = Action("test_action", dummy_action) - cmd = Command( - key="TEST", - description="Test Command", - action=action - ) + cmd = Command(key="TEST", description="Test Command", action=action) print(cmd) - assert str(cmd) == "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, args=(), kwargs={}, retry=False)')" + assert ( + str(cmd) + == "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, args=(), kwargs={}, retry=False)')" + ) + @pytest.mark.parametrize( "action_factory, expected_requires_input", [ (lambda: Action(name="normal", action=dummy_action), False), (lambda: DummyInputAction(name="io"), True), - (lambda: ChainedAction(name="chain", actions=[DummyInputAction(name="io")]), True), - (lambda: ActionGroup(name="group", actions=[DummyInputAction(name="io")]), True), - ] + ( + lambda: ChainedAction(name="chain", actions=[DummyInputAction(name="io")]), + True, + ), + ( + lambda: ActionGroup(name="group", actions=[DummyInputAction(name="io")]), + True, + ), + ], ) def test_command_requires_input_detection(action_factory, expected_requires_input): action = action_factory() - cmd = Command( - key="TEST", - description="Test Command", - action=action - ) + cmd = Command(key="TEST", description="Test Command", action=action) assert cmd.requires_input == expected_requires_input if expected_requires_input: @@ -75,6 +78,7 @@ def test_command_requires_input_detection(action_factory, expected_requires_inpu else: assert cmd.hidden is False + def test_requires_input_flag_detected_for_baseioaction(): """Command should automatically detect requires_input=True for BaseIOAction.""" cmd = Command( @@ -85,6 +89,7 @@ def test_requires_input_flag_detected_for_baseioaction(): assert cmd.requires_input is True assert cmd.hidden is True + def test_requires_input_manual_override(): """Command manually set requires_input=False should not auto-hide.""" cmd = Command( @@ -96,6 +101,7 @@ def test_requires_input_manual_override(): assert cmd.requires_input is False assert cmd.hidden is False + def test_default_command_does_not_require_input(): """Normal Command without IO Action should not require input.""" cmd = Command( @@ -106,6 +112,7 @@ def test_default_command_does_not_require_input(): assert cmd.requires_input is False assert cmd.hidden is False + def test_chain_requires_input(): """If first action in a chain requires input, the command should require input.""" chain = ChainedAction( @@ -123,6 +130,7 @@ def test_chain_requires_input(): assert cmd.requires_input is True assert cmd.hidden is True + def test_group_requires_input(): """If any action in a group requires input, the command should require input.""" group = ActionGroup( @@ -155,6 +163,7 @@ def test_enable_retry(): assert cmd.retry is True assert cmd.action.retry_policy.enabled is True + def test_enable_retry_with_retry_policy(): """Command should enable retry if action is an Action and retry_policy is set.""" retry_policy = RetryPolicy( @@ -175,6 +184,7 @@ def test_enable_retry_with_retry_policy(): assert cmd.action.retry_policy.enabled is True assert cmd.action.retry_policy == retry_policy + def test_enable_retry_not_action(): """Command should not enable retry if action is not an Action.""" cmd = Command( @@ -188,6 +198,7 @@ def test_enable_retry_not_action(): assert cmd.action.retry_policy.enabled is False assert "'function' object has no attribute 'retry_policy'" in str(exc_info.value) + def test_chain_retry_all(): """retry_all should retry all Actions inside a ChainedAction recursively.""" chain = ChainedAction( @@ -209,6 +220,7 @@ def test_chain_retry_all(): assert chain.actions[0].retry_policy.enabled is True assert chain.actions[1].retry_policy.enabled is True + def test_chain_retry_all_not_base_action(): """retry_all should not be set if action is not a ChainedAction.""" cmd = Command( @@ -221,4 +233,3 @@ def test_chain_retry_all_not_base_action(): with pytest.raises(Exception) as exc_info: assert cmd.action.retry_policy.enabled is False assert "'function' object has no attribute 'retry_policy'" in str(exc_info.value) - diff --git a/tests/test_stress_actions.py b/tests/test_stress_actions.py index 67202e4..d1740b0 100644 --- a/tests/test_stress_actions.py +++ b/tests/test_stress_actions.py @@ -1,9 +1,11 @@ -import pytest import asyncio -from falyx.action import Action, ChainedAction, ActionGroup, FallbackAction + +import pytest + +from falyx.action import Action, ActionGroup, ChainedAction, FallbackAction +from falyx.context import ExecutionContext from falyx.execution_registry import ExecutionRegistry as er from falyx.hook_manager import HookType -from falyx.context import ExecutionContext # --- Fixtures ---