From 91c4d5481f61e87d81901868c725c6209988245a Mon Sep 17 00:00:00 2001 From: Roland Thomas Date: Sun, 4 May 2025 14:11:03 -0400 Subject: [PATCH] Add MenuAction, SelectionAction, SignalAction, never_prompt(options_manager propagation), Merged prepare --- examples/action_example.py | 10 +- examples/simple.py | 1 + falyx/__main__.py | 3 +- falyx/action.py | 64 +++++-- falyx/command.py | 11 +- falyx/context.py | 89 +++++++++- falyx/falyx.py | 51 +++--- falyx/importer.py | 30 ---- falyx/menu_action.py | 217 +++++++++++++++++++++++ falyx/options_manager.py | 4 +- falyx/parsers.py | 7 +- falyx/retry.py | 15 +- falyx/retry_utils.py | 1 + falyx/selection.py | 354 +++++++++++++++++++++++++++++++++++++ falyx/selection_action.py | 169 ++++++++++++++++++ falyx/signal_action.py | 29 +++ falyx/signals.py | 21 +++ falyx/tagged_table.py | 1 + falyx/utils.py | 28 ++- falyx/validators.py | 30 ++++ falyx/version.py | 2 +- pyproject.toml | 3 +- tests/test_action_basic.py | 101 +++++++++++ tests/test_headless.py | 45 +++++ 24 files changed, 1177 insertions(+), 109 deletions(-) delete mode 100644 falyx/importer.py create mode 100644 falyx/menu_action.py create mode 100644 falyx/selection.py create mode 100644 falyx/selection_action.py create mode 100644 falyx/signal_action.py create mode 100644 falyx/signals.py create mode 100644 falyx/validators.py create mode 100644 tests/test_headless.py diff --git a/examples/action_example.py b/examples/action_example.py index 9002b71..6b1ec3f 100644 --- a/examples/action_example.py +++ b/examples/action_example.py @@ -9,10 +9,10 @@ def hello() -> None: print("Hello, world!") -hello = Action(name="hello_action", action=hello) +hello_action = Action(name="hello_action", action=hello) # Actions can be run by themselves or as part of a command or pipeline -asyncio.run(hello()) +asyncio.run(hello_action()) # Actions are designed to be asynchronous first @@ -20,14 +20,14 @@ async def goodbye() -> None: print("Goodbye!") -goodbye = Action(name="goodbye_action", action=goodbye) +goodbye_action = Action(name="goodbye_action", action=goodbye) asyncio.run(goodbye()) # Actions can be run in parallel -group = ActionGroup(name="greeting_group", actions=[hello, goodbye]) +group = ActionGroup(name="greeting_group", actions=[hello_action, goodbye_action]) asyncio.run(group()) # Actions can be run in a chain -chain = ChainedAction(name="greeting_chain", actions=[hello, goodbye]) +chain = ChainedAction(name="greeting_chain", actions=[hello_action, goodbye_action]) asyncio.run(chain()) diff --git a/examples/simple.py b/examples/simple.py index 2d9464f..1139928 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -12,6 +12,7 @@ async def flaky_step(): await asyncio.sleep(0.2) if random.random() < 0.5: raise RuntimeError("Random failure!") + print("Flaky step succeeded!") return "ok" diff --git a/falyx/__main__.py b/falyx/__main__.py index 2b2c3c0..367a901 100644 --- a/falyx/__main__.py +++ b/falyx/__main__.py @@ -29,8 +29,7 @@ def bootstrap() -> Path | None: config_path = find_falyx_config() if config_path and str(config_path.parent) not in sys.path: sys.path.insert(0, str(config_path.parent)) - return config_path - return None + return config_path def main() -> None: diff --git a/falyx/action.py b/falyx/action.py index a198a10..923530d 100644 --- a/falyx/action.py +++ b/falyx/action.py @@ -43,6 +43,7 @@ from falyx.debug import register_debug_hooks from falyx.exceptions import EmptyChainError from falyx.execution_registry import ExecutionRegistry as er from falyx.hook_manager import Hook, HookManager, HookType +from falyx.options_manager import OptionsManager from falyx.retry import RetryHandler, RetryPolicy from falyx.themes.colors import OneColors from falyx.utils import ensure_async, logger @@ -66,6 +67,7 @@ class BaseAction(ABC): hooks: HookManager | None = None, inject_last_result: bool = False, inject_last_result_as: str = "last_result", + never_prompt: bool = False, logging_hooks: bool = False, ) -> None: self.name = name @@ -74,9 +76,11 @@ class BaseAction(ABC): self.shared_context: SharedContext | None = None self.inject_last_result: bool = inject_last_result self.inject_last_result_as: str = inject_last_result_as + self._never_prompt: bool = never_prompt self._requires_injection: bool = False self._skip_in_chain: bool = False self.console = Console(color_system="auto") + self.options_manager: OptionsManager | None = None if logging_hooks: register_debug_hooks(self.hooks) @@ -92,23 +96,39 @@ class BaseAction(ABC): async def preview(self, parent: Tree | None = None): raise NotImplementedError("preview must be implemented by subclasses") - def set_shared_context(self, shared_context: SharedContext): + def set_options_manager(self, options_manager: OptionsManager) -> None: + self.options_manager = options_manager + + def set_shared_context(self, shared_context: SharedContext) -> None: self.shared_context = shared_context - def prepare_for_chain(self, shared_context: SharedContext) -> BaseAction: + def get_option(self, option_name: str, default: Any = None) -> Any: + """Resolve an option from the OptionsManager if present, otherwise use the fallback.""" + if self.options_manager: + return self.options_manager.get(option_name, default) + return default + + @property + def last_result(self) -> Any: + """Return the last result from the shared context.""" + if self.shared_context: + return self.shared_context.last_result() + return None + + @property + def never_prompt(self) -> bool: + return self.get_option("never_prompt", self._never_prompt) + + def prepare( + self, shared_context: SharedContext, options_manager: OptionsManager | None = None + ) -> BaseAction: """ Prepare the action specifically for sequential (ChainedAction) execution. Can be overridden for chain-specific logic. """ self.set_shared_context(shared_context) - return self - - def prepare_for_group(self, shared_context: SharedContext) -> BaseAction: - """ - Prepare the action specifically for parallel (ActionGroup) execution. - Can be overridden for group-specific logic. - """ - self.set_shared_context(shared_context) + if options_manager: + self.set_options_manager(options_manager) return self def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]: @@ -161,8 +181,8 @@ class Action(BaseAction): def __init__( self, name: str, - action, - rollback=None, + action: Callable[..., Any], + rollback: Callable[..., Any] | None = None, args: tuple[Any, ...] = (), kwargs: dict[str, Any] | None = None, hooks: HookManager | None = None, @@ -189,6 +209,17 @@ class Action(BaseAction): def action(self, value: Callable[..., Any]): self._action = ensure_async(value) + @property + def rollback(self) -> Callable[..., Any] | None: + return self._rollback + + @rollback.setter + def rollback(self, value: Callable[..., Any] | None): + if value is None: + self._rollback = None + else: + self._rollback = ensure_async(value) + def enable_retry(self): """Enable retry with the existing retry policy.""" self.retry_policy.enable_policy() @@ -212,6 +243,7 @@ class Action(BaseAction): kwargs=combined_kwargs, action=self, ) + context.start_timer() try: await self.hooks.trigger(HookType.BEFORE, context) @@ -425,7 +457,7 @@ class ChainedAction(BaseAction, ActionListMixin): ) continue shared_context.current_index = index - prepared = action.prepare_for_chain(shared_context) + prepared = action.prepare(shared_context, self.options_manager) last_result = shared_context.last_result() try: if self.requires_io_injection() and last_result is not None: @@ -446,9 +478,7 @@ class ChainedAction(BaseAction, ActionListMixin): ) 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(shared_context) result = await fallback() fallback._skip_in_chain = True else: @@ -584,7 +614,7 @@ class ActionGroup(BaseAction, ActionListMixin): async def run_one(action: BaseAction): try: - prepared = action.prepare_for_group(shared_context) + prepared = action.prepare(shared_context, self.options_manager) result = await prepared(*args, **updated_kwargs) shared_context.add_result((action.name, result)) context.extra["results"].append((action.name, result)) diff --git a/falyx/command.py b/falyx/command.py index 4936b47..999e2cc 100644 --- a/falyx/command.py +++ b/falyx/command.py @@ -32,6 +32,7 @@ from falyx.debug import register_debug_hooks from falyx.execution_registry import ExecutionRegistry as er from falyx.hook_manager import HookManager, HookType from falyx.io_action import BaseIOAction +from falyx.options_manager import OptionsManager from falyx.retry import RetryPolicy from falyx.retry_utils import enable_retries_recursively from falyx.themes.colors import OneColors @@ -116,6 +117,7 @@ class Command(BaseModel): tags: list[str] = Field(default_factory=list) logging_hooks: bool = False requires_input: bool | None = None + options_manager: OptionsManager = Field(default_factory=OptionsManager) _context: ExecutionContext | None = PrivateAttr(default=None) @@ -178,8 +180,14 @@ class Command(BaseModel): f"action='{self.action}')" ) + def _inject_options_manager(self): + """Inject the options manager into the action if applicable.""" + if isinstance(self.action, BaseAction): + self.action.set_options_manager(self.options_manager) + async def __call__(self, *args, **kwargs): """Run the action with full hook lifecycle, timing, and error handling.""" + self._inject_options_manager() combined_args = args + self.args combined_kwargs = {**self.kwargs, **kwargs} context = ExecutionContext( @@ -200,9 +208,6 @@ class Command(BaseModel): except Exception as error: context.exception = error await self.hooks.trigger(HookType.ON_ERROR, context) - if context.result is not None: - logger.info(f"✅ Recovered: {self.key}") - return context.result raise error finally: context.stop_timer() diff --git a/falyx/context.py b/falyx/context.py index f1be3fc..98f34e0 100644 --- a/falyx/context.py +++ b/falyx/context.py @@ -1,5 +1,20 @@ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed -"""context.py""" +""" +Execution context management for Falyx CLI actions. + +This module defines `ExecutionContext` and `SharedContext`, which are responsible for +capturing per-action and cross-action metadata during CLI workflow execution. These +context objects provide structured introspection, result tracking, error recording, +and time-based performance metrics. + +- `ExecutionContext`: Captures runtime information for a single action execution, + including arguments, results, exceptions, timing, and logging. +- `SharedContext`: Maintains shared state and result propagation across + `ChainedAction` or `ActionGroup` executions. + +These contexts enable rich introspection, traceability, and workflow coordination, +supporting hook lifecycles, retries, and structured output generation. +""" from __future__ import annotations import time @@ -11,6 +26,47 @@ from rich.console import Console class ExecutionContext(BaseModel): + """ + Represents the runtime metadata and state for a single action execution. + + The `ExecutionContext` tracks arguments, results, exceptions, timing, and additional + metadata for each invocation of a Falyx `BaseAction`. It provides integration with the + Falyx hook system and execution registry, enabling lifecycle management, diagnostics, + and structured logging. + + Attributes: + name (str): The name of the action being executed. + args (tuple): Positional arguments passed to the action. + kwargs (dict): Keyword arguments passed to the action. + action (BaseAction | Callable): The action instance being executed. + result (Any | None): The result of the action, if successful. + exception (Exception | None): The exception raised, if execution failed. + start_time (float | None): High-resolution performance start time. + end_time (float | None): High-resolution performance end time. + start_wall (datetime | None): Wall-clock timestamp when execution began. + end_wall (datetime | None): Wall-clock timestamp when execution ended. + extra (dict): Metadata for custom introspection or special use by Actions. + console (Console): Rich console instance for logging or UI output. + shared_context (SharedContext | None): Optional shared context when running in a chain or group. + + Properties: + duration (float | None): The execution duration in seconds. + success (bool): Whether the action completed without raising an exception. + status (str): Returns "OK" if successful, otherwise "ERROR". + + Methods: + start_timer(): Starts the timing and timestamp tracking. + stop_timer(): Stops timing and stores end timestamps. + log_summary(logger=None): Logs a rich or plain summary of execution. + to_log_line(): Returns a single-line log entry for metrics or tracing. + as_dict(): Serializes core result and diagnostic metadata. + get_shared_context(): Returns the shared context or creates a default one. + + This class is used internally by all Falyx actions and hook events. It ensures + consistent tracking and reporting across asynchronous workflows, including CLI-driven + and automated batch executions. + """ + name: str args: tuple = () kwargs: dict = {} @@ -120,6 +176,37 @@ class ExecutionContext(BaseModel): class SharedContext(BaseModel): + """ + SharedContext maintains transient shared state during the execution + of a ChainedAction or ActionGroup. + + This context object is passed to all actions within a chain or group, + enabling result propagation, shared data exchange, and coordinated + tracking of execution order and failures. + + Attributes: + name (str): Identifier for the context (usually the parent action name). + results (list[Any]): Captures results from each action, in order of execution. + errors (list[tuple[int, Exception]]): Indexed list of errors from failed actions. + current_index (int): Index of the currently executing action (used in chains). + is_parallel (bool): Whether the context is used in parallel mode (ActionGroup). + shared_result (Any | None): Optional shared value available to all actions in parallel mode. + share (dict[str, Any]): Custom shared key-value store for user-defined communication + between actions (e.g., flags, intermediate data, settings). + + Note: + SharedContext is only used within grouped or chained workflows. It should not be + used for standalone `Action` executions, where state should be scoped to the + individual ExecutionContext instead. + + Example usage: + - In a ChainedAction: last_result is pulled from `results[-1]`. + - In an ActionGroup: all actions can read/write `shared_result` or use `share`. + + This class supports fault-tolerant and modular composition of CLI workflows + by enabling flexible intra-action communication without global state. + """ + name: str results: list[Any] = Field(default_factory=list) errors: list[tuple[int, Exception]] = Field(default_factory=list) diff --git a/falyx/falyx.py b/falyx/falyx.py index ec3e7e2..19bc341 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -53,11 +53,12 @@ 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.signals import BackSignal, QuitSignal from falyx.themes.colors import OneColors, get_nord_theme from falyx.utils import ( CaseInsensitiveDict, - async_confirm, chunks, + confirm_async, get_program_invocation, logger, ) @@ -93,7 +94,7 @@ class Falyx: include_history_command (bool): Whether to add a built-in history viewer command. include_help_command (bool): Whether to add a built-in help viewer command. confirm_on_error (bool): Whether to prompt the user after errors. - never_confirm (bool): Whether to skip confirmation prompts entirely. + never_prompt (bool): Whether to skip confirmation prompts entirely. always_confirm (bool): Whether to force confirmation prompts for all actions. cli_args (Namespace | None): Parsed CLI arguments, usually from argparse. options (OptionsManager | None): Declarative option mappings. @@ -123,7 +124,7 @@ class Falyx: include_history_command: bool = True, include_help_command: bool = True, confirm_on_error: bool = True, - never_confirm: bool = False, + never_prompt: bool = False, always_confirm: bool = False, cli_args: Namespace | None = None, options: OptionsManager | None = None, @@ -150,7 +151,7 @@ class Falyx: self.key_bindings: KeyBindings = key_bindings or KeyBindings() self.bottom_bar: BottomBar | str | Callable[[], None] = bottom_bar self.confirm_on_error: bool = confirm_on_error - self._never_confirm: bool = never_confirm + self._never_prompt: bool = never_prompt self._always_confirm: bool = always_confirm self.cli_args: Namespace | None = cli_args self.render_menu: Callable[["Falyx"], None] | None = render_menu @@ -166,7 +167,7 @@ class Falyx: """Checks if the options are set correctly.""" self.options: OptionsManager = options or OptionsManager() if not cli_args and not options: - return + return None if options and not cli_args: raise FalyxError("Options are set, but CLI arguments are not.") @@ -521,8 +522,9 @@ class Falyx: def update_exit_command( self, - key: str = "0", + key: str = "Q", description: str = "Exit", + aliases: list[str] | None = None, action: Callable[[], Any] = lambda: None, color: str = OneColors.DARK_RED, confirm: bool = False, @@ -535,6 +537,7 @@ class Falyx: self.exit_command = Command( key=key, description=description, + aliases=aliases if aliases else self.exit_command.aliases, action=action, color=color, confirm=confirm, @@ -549,6 +552,7 @@ class Falyx: raise NotAFalyxError("submenu must be an instance of Falyx.") self._validate_command_key(key) self.add_command(key, description, submenu.menu, color=color) + submenu.update_exit_command(key="B", description="Back", aliases=["BACK"]) def add_commands(self, commands: list[dict]) -> None: """Adds multiple commands to the menu.""" @@ -613,6 +617,7 @@ class Falyx: retry_all=retry_all, retry_policy=retry_policy or RetryPolicy(), requires_input=requires_input, + options_manager=self.options, ) if hooks: @@ -703,7 +708,7 @@ class Falyx: return None async def _should_run_action(self, selected_command: Command) -> bool: - if self._never_confirm: + if self._never_prompt: return True if self.cli_args and getattr(self.cli_args, "skip_confirm", False): @@ -717,7 +722,7 @@ class Falyx: ): if selected_command.preview_before_confirm: await selected_command.preview() - confirm_answer = await async_confirm(selected_command.confirmation_prompt) + confirm_answer = await confirm_async(selected_command.confirmation_prompt) if confirm_answer: logger.info(f"[{selected_command.description}]🔐 confirmed.") @@ -747,18 +752,13 @@ class Falyx: async def _handle_action_error( self, selected_command: Command, error: Exception - ) -> bool: + ) -> None: """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}" ) - 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 async def process_command(self) -> bool: """Processes the action of the selected command.""" @@ -801,13 +801,7 @@ class Falyx: except Exception as error: 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}'" - ) - context.result = result - else: - return await self._handle_action_error(selected_command, error) + await self._handle_action_error(selected_command, error) finally: context.stop_timer() await self.hooks.trigger(HookType.AFTER, context) @@ -822,7 +816,7 @@ class Falyx: if not selected_command: logger.info("[Headless] Back command selected. Exiting menu.") - return + return None logger.info(f"[Headless] 🚀 Running: '{selected_command.description}'") @@ -851,11 +845,6 @@ class Falyx: 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}'" - ) - return True raise FalyxError( f"[Headless] ❌ '{selected_command.description}' failed." ) from error @@ -921,6 +910,11 @@ class Falyx: except (EOFError, KeyboardInterrupt): logger.info("EOF or KeyboardInterrupt. Exiting menu.") break + except QuitSignal: + logger.info("QuitSignal received. Exiting menu.") + break + except BackSignal: + logger.info("BackSignal received.") finally: logger.info(f"Exiting menu: {self.get_title()}") if self.exit_message: @@ -938,6 +932,9 @@ class Falyx: logger.debug("✅ Enabling global debug hooks for all commands") self.register_all_with_debug_hooks() + if self.cli_args.never_prompt: + self._never_prompt = True + if self.cli_args.command == "list": await self._show_help() sys.exit(0) diff --git a/falyx/importer.py b/falyx/importer.py deleted file mode 100644 index 4eace21..0000000 --- a/falyx/importer.py +++ /dev/null @@ -1,30 +0,0 @@ -# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed -"""importer.py""" - -import importlib -from types import ModuleType -from typing import Any, Callable - - -def resolve_action(path: str) -> Callable[..., Any]: - """ - Resolve a dotted path to a Python callable. - Example: 'mypackage.mymodule.myfunction' - - Raises: - ImportError if the module or function does not exist. - ValueError if the resolved attribute is not callable. - """ - if ":" in path: - module_path, function_name = path.split(":") - else: - *module_parts, function_name = path.split(".") - module_path = ".".join(module_parts) - - module: ModuleType = importlib.import_module(module_path) - function: Any = getattr(module, function_name) - - if not callable(function): - raise ValueError(f"Resolved attribute '{function_name}' is not callable.") - - return function diff --git a/falyx/menu_action.py b/falyx/menu_action.py new file mode 100644 index 0000000..183e886 --- /dev/null +++ b/falyx/menu_action.py @@ -0,0 +1,217 @@ +# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed +"""menu_action.py""" +from dataclasses import dataclass +from typing import Any + +from prompt_toolkit import PromptSession +from rich.console import Console +from rich.table import Table +from rich.tree import Tree + +from falyx.action import BaseAction +from falyx.context import ExecutionContext +from falyx.execution_registry import ExecutionRegistry as er +from falyx.hook_manager import HookType +from falyx.selection import prompt_for_selection, render_table_base +from falyx.signal_action import SignalAction +from falyx.signals import BackSignal, QuitSignal +from falyx.themes.colors import OneColors +from falyx.utils import CaseInsensitiveDict, chunks, logger + + +@dataclass +class MenuOption: + description: str + action: BaseAction + color: str = OneColors.WHITE + + def __post_init__(self): + if not isinstance(self.description, str): + raise TypeError("MenuOption description must be a string.") + if not isinstance(self.action, BaseAction): + raise TypeError("MenuOption action must be a BaseAction instance.") + + def render(self, key: str) -> str: + """Render the menu option for display.""" + return f"[{OneColors.WHITE}][{key}][/] [{self.color}]{self.description}[/]" + + +class MenuOptionMap(CaseInsensitiveDict): + """ + Manages menu options including validation, reserved key protection, + and special signal entries like Quit and Back. + """ + + RESERVED_KEYS = {"Q", "B"} + + def __init__( + self, options: dict[str, MenuOption] | None = None, allow_reserved: bool = False + ): + super().__init__() + self.allow_reserved = allow_reserved + if options: + self.update(options) + self._inject_reserved_defaults() + + def _inject_reserved_defaults(self): + self._add_reserved( + "Q", + MenuOption("Exit", SignalAction("Quit", QuitSignal()), OneColors.DARK_RED), + ) + self._add_reserved( + "B", + MenuOption("Back", SignalAction("Back", BackSignal()), OneColors.DARK_YELLOW), + ) + + def _add_reserved(self, key: str, option: MenuOption) -> None: + """Add a reserved key, bypassing validation.""" + norm_key = key.upper() + super().__setitem__(norm_key, option) + + def __setitem__(self, key: str, option: MenuOption) -> None: + if not isinstance(option, MenuOption): + raise TypeError(f"Value for key '{key}' must be a MenuOption.") + norm_key = key.upper() + if norm_key in self.RESERVED_KEYS and not self.allow_reserved: + raise ValueError( + f"Key '{key}' is reserved and cannot be used in MenuOptionMap." + ) + super().__setitem__(norm_key, option) + + def __delitem__(self, key: str) -> None: + if key.upper() in self.RESERVED_KEYS and not self.allow_reserved: + raise ValueError(f"Cannot delete reserved option '{key}'.") + super().__delitem__(key) + + def items(self, include_reserved: bool = True): + for k, v in super().items(): + if not include_reserved and k in self.RESERVED_KEYS: + continue + yield k, v + + +class MenuAction(BaseAction): + def __init__( + self, + name: str, + menu_options: MenuOptionMap, + *, + title: str = "Select an option", + columns: int = 2, + prompt_message: str = "Select > ", + default_selection: str = "", + inject_last_result: bool = False, + inject_last_result_as: str = "last_result", + console: Console | None = None, + prompt_session: PromptSession | None = None, + never_prompt: bool = False, + include_reserved: bool = True, + show_table: bool = True, + ): + super().__init__( + name, + inject_last_result=inject_last_result, + inject_last_result_as=inject_last_result_as, + never_prompt=never_prompt, + ) + self.menu_options = menu_options + self.title = title + self.columns = columns + self.prompt_message = prompt_message + self.default_selection = default_selection + self.console = console or Console(color_system="auto") + self.prompt_session = prompt_session or PromptSession() + self.include_reserved = include_reserved + self.show_table = show_table + + def _build_table(self) -> Table: + table = render_table_base( + title=self.title, + columns=self.columns, + ) + for chunk in chunks( + self.menu_options.items(include_reserved=self.include_reserved), self.columns + ): + row = [] + for key, option in chunk: + row.append(option.render(key)) + table.add_row(*row) + return table + + async def _run(self, *args, **kwargs) -> Any: + kwargs = self._maybe_inject_last_result(kwargs) + context = ExecutionContext( + name=self.name, + args=args, + kwargs=kwargs, + action=self, + ) + + effective_default = self.default_selection + maybe_result = str(self.last_result) + if maybe_result in self.menu_options: + effective_default = maybe_result + elif self.inject_last_result: + logger.warning( + "[%s] Injected last result '%s' not found in menu options", + self.name, + maybe_result, + ) + + if self.never_prompt and not effective_default: + raise ValueError( + f"[{self.name}] 'never_prompt' is True but no valid default_selection was provided." + ) + + context.start_timer() + try: + await self.hooks.trigger(HookType.BEFORE, context) + key = effective_default + if not self.never_prompt: + console = self.console + session = self.prompt_session + table = self._build_table() + key = await prompt_for_selection( + self.menu_options.keys(), + table, + default_selection=self.default_selection, + console=console, + session=session, + prompt_message=self.prompt_message, + show_table=self.show_table, + ) + option = self.menu_options[key] + result = await option.action(*args, **kwargs) + context.result = result + await self.hooks.trigger(HookType.ON_SUCCESS, context) + return result + + except BackSignal: + logger.debug("[%s][BackSignal] ← Returning to previous menu", self.name) + return None + except QuitSignal: + logger.debug("[%s][QuitSignal] ← Exiting application", self.name) + raise + except Exception as error: + context.exception = error + await self.hooks.trigger(HookType.ON_ERROR, context) + raise + finally: + context.stop_timer() + await self.hooks.trigger(HookType.AFTER, context) + await self.hooks.trigger(HookType.ON_TEARDOWN, context) + er.record(context) + + async def preview(self, parent: Tree | None = None): + label = f"[{OneColors.DARK_YELLOW_b}]📋 MenuAction[/] '{self.name}'" + tree = parent.add(label) if parent else Tree(label) + for key, option in self.menu_options.items(): + tree.add( + f"[dim]{key}[/]: {option.description} → [italic]{option.action.name}[/]" + ) + await option.action.preview(parent=tree) + if not parent: + self.console.print(tree) + + def __str__(self) -> str: + return f"MenuAction(name={self.name}, options={list(self.menu_options.keys())})" diff --git a/falyx/options_manager.py b/falyx/options_manager.py index 4fdea69..9facd4a 100644 --- a/falyx/options_manager.py +++ b/falyx/options_manager.py @@ -9,8 +9,8 @@ from falyx.utils import logger class OptionsManager: - def __init__(self, namespaces: list[tuple[str, Namespace]] = None) -> None: - self.options = defaultdict(lambda: Namespace()) + def __init__(self, namespaces: list[tuple[str, Namespace]] | None = None) -> None: + self.options: defaultdict = defaultdict(lambda: Namespace()) if namespaces: for namespace_name, namespace in namespaces: self.from_namespace(namespace, namespace_name) diff --git a/falyx/parsers.py b/falyx/parsers.py index 6d74485..49fa327 100644 --- a/falyx/parsers.py +++ b/falyx/parsers.py @@ -37,7 +37,6 @@ def get_arg_parsers( 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, @@ -53,7 +52,6 @@ def get_arg_parsers( description=description, epilog=epilog, parents=parents, - formatter_class=formatter_class, prefix_chars=prefix_chars, fromfile_prefix_chars=fromfile_prefix_chars, argument_default=argument_default, @@ -62,6 +60,11 @@ def get_arg_parsers( allow_abbrev=allow_abbrev, exit_on_error=exit_on_error, ) + parser.add_argument( + "--never-prompt", + action="store_true", + help="Run in non-interactive mode with all prompts bypassed.", + ) parser.add_argument( "-v", "--verbose", action="store_true", help="Enable debug logging for Falyx." ) diff --git a/falyx/retry.py b/falyx/retry.py index d2279fc..c03b2d7 100644 --- a/falyx/retry.py +++ b/falyx/retry.py @@ -43,7 +43,7 @@ class RetryHandler: delay: float = 1.0, backoff: float = 2.0, jitter: float = 0.0, - ): + ) -> None: self.policy.enabled = True self.policy.max_retries = max_retries self.policy.delay = delay @@ -51,7 +51,7 @@ class RetryHandler: self.policy.jitter = jitter logger.info(f"🔄 Retry policy enabled: {self.policy}") - async def retry_on_error(self, context: ExecutionContext): + async def retry_on_error(self, context: ExecutionContext) -> None: from falyx.action import Action name = context.name @@ -64,21 +64,21 @@ class RetryHandler: if not target: logger.warning(f"[{name}] ⚠️ No action target. Cannot retry.") - return + return None if not isinstance(target, Action): logger.warning( f"[{name}] ❌ RetryHandler only supports only supports Action objects." ) - return + return None if not getattr(target, "is_retryable", False): logger.warning(f"[{name}] ❌ Not retryable.") - return + return None if not self.policy.enabled: logger.warning(f"[{name}] ❌ Retry policy is disabled.") - return + return None while retries_done < self.policy.max_retries: retries_done += 1 @@ -97,7 +97,7 @@ class RetryHandler: context.result = result context.exception = None logger.info(f"[{name}] ✅ Retry succeeded on attempt {retries_done}.") - return + return None except Exception as retry_error: last_error = retry_error current_delay *= self.policy.backoff @@ -108,4 +108,3 @@ class RetryHandler: context.exception = last_error logger.error(f"[{name}] ❌ All {self.policy.max_retries} retries failed.") - return diff --git a/falyx/retry_utils.py b/falyx/retry_utils.py index 880955f..5989d08 100644 --- a/falyx/retry_utils.py +++ b/falyx/retry_utils.py @@ -1,3 +1,4 @@ +# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed from falyx.action import Action, BaseAction from falyx.hook_manager import HookType from falyx.retry import RetryHandler, RetryPolicy diff --git a/falyx/selection.py b/falyx/selection.py new file mode 100644 index 0000000..a85a2a5 --- /dev/null +++ b/falyx/selection.py @@ -0,0 +1,354 @@ +# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed +"""selection.py""" +from typing import Any, Callable, KeysView, Sequence + +from prompt_toolkit import PromptSession +from rich import box +from rich.console import Console +from rich.markup import escape +from rich.table import Table + +from falyx.themes.colors import OneColors +from falyx.utils import chunks +from falyx.validators import int_range_validator, key_validator + + +def render_table_base( + title: str, + caption: str = "", + columns: int = 4, + box_style: box.Box = box.SIMPLE, + show_lines: bool = False, + show_header: bool = False, + show_footer: bool = False, + style: str = "", + header_style: str = "", + footer_style: str = "", + title_style: str = "", + caption_style: str = "", + highlight: bool = True, + column_names: Sequence[str] | None = None, +) -> Table: + table = Table( + title=title, + caption=caption, + box=box_style, + show_lines=show_lines, + show_header=show_header, + show_footer=show_footer, + style=style, + header_style=header_style, + footer_style=footer_style, + title_style=title_style, + caption_style=caption_style, + highlight=highlight, + ) + if column_names: + for column_name in column_names: + table.add_column(column_name) + else: + for _ in range(columns): + table.add_column() + return table + + +def render_selection_grid( + title: str, + selections: Sequence[str], + columns: int = 4, + caption: str = "", + box_style: box.Box = box.SIMPLE, + show_lines: bool = False, + show_header: bool = False, + show_footer: bool = False, + style: str = "", + header_style: str = "", + footer_style: str = "", + title_style: str = "", + caption_style: str = "", + highlight: bool = False, +) -> Table: + """Create a selection table with the given parameters.""" + table = render_table_base( + title, + caption, + columns, + box_style, + show_lines, + show_header, + show_footer, + style, + header_style, + footer_style, + title_style, + caption_style, + highlight, + ) + + for chunk in chunks(selections, columns): + table.add_row(*chunk) + + return table + + +def render_selection_indexed_table( + title: str, + selections: Sequence[str], + columns: int = 4, + caption: str = "", + box_style: box.Box = box.SIMPLE, + show_lines: bool = False, + show_header: bool = False, + show_footer: bool = False, + style: str = "", + header_style: str = "", + footer_style: str = "", + title_style: str = "", + caption_style: str = "", + highlight: bool = False, + formatter: Callable[[int, str], str] | None = None, +) -> Table: + """Create a selection table with the given parameters.""" + table = render_table_base( + title, + caption, + columns, + box_style, + show_lines, + show_header, + show_footer, + style, + header_style, + footer_style, + title_style, + caption_style, + highlight, + ) + + for indexes, chunk in zip( + chunks(range(len(selections)), columns), chunks(selections, columns) + ): + row = [ + formatter(index, selection) if formatter else f"{index}: {selection}" + for index, selection in zip(indexes, chunk) + ] + table.add_row(*row) + + return table + + +def render_selection_dict_table( + title: str, + selections: dict[str, tuple[str, Any]], + columns: int = 2, + caption: str = "", + box_style: box.Box = box.SIMPLE, + show_lines: bool = False, + show_header: bool = False, + show_footer: bool = False, + style: str = "", + header_style: str = "", + footer_style: str = "", + title_style: str = "", + caption_style: str = "", + highlight: bool = False, +) -> Table: + """Create a selection table with the given parameters.""" + table = render_table_base( + title, + caption, + columns, + box_style, + show_lines, + show_header, + show_footer, + style, + header_style, + footer_style, + title_style, + caption_style, + highlight, + ) + + for chunk in chunks(selections.items(), columns): + row = [] + for key, value in chunk: + row.append(f"[{OneColors.WHITE}][{key.upper()}] {value[0]}") + table.add_row(*row) + + return table + + +async def prompt_for_index( + max_index: int, + table: Table, + min_index: int = 0, + default_selection: str = "", + console: Console | None = None, + session: PromptSession | None = None, + prompt_message: str = "Select an option > ", + show_table: bool = True, +): + session = session or PromptSession() + console = console or Console(color_system="auto") + + if show_table: + console.print(table) + + selection = await session.prompt_async( + message=prompt_message, + validator=int_range_validator(min_index, max_index), + default=default_selection, + ) + return int(selection) + + +async def prompt_for_selection( + keys: Sequence[str] | KeysView[str], + table: Table, + default_selection: str = "", + console: Console | None = None, + session: PromptSession | None = None, + prompt_message: str = "Select an option > ", + show_table: bool = True, +) -> str: + """Prompt the user to select a key from a set of options. Return the selected key.""" + session = session or PromptSession() + console = console or Console(color_system="auto") + + if show_table: + console.print(table, justify="center") + + selected = await session.prompt_async( + message=prompt_message, + validator=key_validator(keys), + default=default_selection, + ) + + return selected + + +async def select_value_from_list( + title: str, + selections: Sequence[str], + console: Console | None = None, + session: PromptSession | None = None, + prompt_message: str = "Select an option > ", + default_selection: str = "", + columns: int = 4, + caption: str = "", + box_style: box.Box = box.SIMPLE, + show_lines: bool = False, + show_header: bool = False, + show_footer: bool = False, + style: str = "", + header_style: str = "", + footer_style: str = "", + title_style: str = "", + caption_style: str = "", + highlight: bool = False, +): + """Prompt for a selection. Return the selected item.""" + table = render_selection_indexed_table( + title, + selections, + columns, + caption, + box_style, + show_lines, + show_header, + show_footer, + style, + header_style, + footer_style, + title_style, + caption_style, + highlight, + ) + session = session or PromptSession() + console = console or Console(color_system="auto") + + selection_index = await prompt_for_index( + len(selections) - 1, + table, + default_selection=default_selection, + console=console, + session=session, + prompt_message=prompt_message, + ) + + return selections[selection_index] + + +async def select_key_from_dict( + selections: dict[str, tuple[str, Any]], + table: Table, + console: Console | None = None, + session: PromptSession | None = None, + prompt_message: str = "Select an option > ", + default_selection: str = "", +) -> Any: + """Prompt for a key from a dict, returns the key.""" + session = session or PromptSession() + console = console or Console(color_system="auto") + + console.print(table) + + return await prompt_for_selection( + selections.keys(), + table, + default_selection=default_selection, + console=console, + session=session, + prompt_message=prompt_message, + ) + + +async def select_value_from_dict( + selections: dict[str, tuple[str, Any]], + table: Table, + console: Console | None = None, + session: PromptSession | None = None, + prompt_message: str = "Select an option > ", + default_selection: str = "", +) -> Any: + """Prompt for a key from a dict, but return the value.""" + session = session or PromptSession() + console = console or Console(color_system="auto") + + console.print(table) + + selection_key = await prompt_for_selection( + selections.keys(), + table, + default_selection=default_selection, + console=console, + session=session, + prompt_message=prompt_message, + ) + + return selections[selection_key][1] + + +async def get_selection_from_dict_menu( + title: str, + selections: dict[str, tuple[str, Any]], + console: Console | None = None, + session: PromptSession | None = None, + prompt_message: str = "Select an option > ", + default_selection: str = "", +): + """Prompt for a key from a dict, but return the value.""" + table = render_selection_dict_table( + title, + selections, + ) + + return await select_value_from_dict( + selections, + table, + console, + session, + prompt_message, + default_selection, + ) diff --git a/falyx/selection_action.py b/falyx/selection_action.py new file mode 100644 index 0000000..5cc4b74 --- /dev/null +++ b/falyx/selection_action.py @@ -0,0 +1,169 @@ +# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed +"""selection_action.py""" +from typing import Any + +from prompt_toolkit import PromptSession +from rich.console import Console +from rich.tree import Tree + +from falyx.action import BaseAction +from falyx.context import ExecutionContext +from falyx.execution_registry import ExecutionRegistry as er +from falyx.hook_manager import HookType +from falyx.selection import ( + prompt_for_index, + prompt_for_selection, + render_selection_dict_table, + render_selection_indexed_table, +) +from falyx.themes.colors import OneColors +from falyx.utils import logger + + +class SelectionAction(BaseAction): + def __init__( + self, + name: str, + selections: list[str] | dict[str, tuple[str, Any]], + *, + title: str = "Select an option", + columns: int = 2, + prompt_message: str = "Select > ", + default_selection: str = "", + inject_last_result: bool = False, + inject_last_result_as: str = "last_result", + return_key: bool = False, + console: Console | None = None, + session: PromptSession | None = None, + never_prompt: bool = False, + show_table: bool = True, + ): + super().__init__( + name, + inject_last_result=inject_last_result, + inject_last_result_as=inject_last_result_as, + never_prompt=never_prompt, + ) + self.selections = selections + self.return_key = return_key + self.title = title + self.columns = columns + self.console = console or Console(color_system="auto") + self.session = session or PromptSession() + self.default_selection = default_selection + self.prompt_message = prompt_message + self.show_table = show_table + + async def _run(self, *args, **kwargs) -> Any: + kwargs = self._maybe_inject_last_result(kwargs) + context = ExecutionContext( + name=self.name, + args=args, + kwargs=kwargs, + action=self, + ) + + effective_default = str(self.default_selection) + maybe_result = str(self.last_result) + if isinstance(self.selections, dict): + if maybe_result in self.selections: + effective_default = maybe_result + elif isinstance(self.selections, list): + if maybe_result.isdigit() and int(maybe_result) in range( + len(self.selections) + ): + effective_default = maybe_result + elif self.inject_last_result: + logger.warning( + "[%s] Injected last result '%s' not found in selections", + self.name, + maybe_result, + ) + + if self.never_prompt and not effective_default: + raise ValueError( + f"[{self.name}] 'never_prompt' is True but no valid default_selection was provided." + ) + + context.start_timer() + try: + await self.hooks.trigger(HookType.BEFORE, context) + if isinstance(self.selections, list): + table = render_selection_indexed_table( + self.title, self.selections, self.columns + ) + if not self.never_prompt: + index = await prompt_for_index( + len(self.selections) - 1, + table, + default_selection=effective_default, + console=self.console, + session=self.session, + prompt_message=self.prompt_message, + show_table=self.show_table, + ) + else: + index = effective_default + result = self.selections[int(index)] + elif isinstance(self.selections, dict): + table = render_selection_dict_table( + self.title, self.selections, self.columns + ) + if not self.never_prompt: + key = await prompt_for_selection( + self.selections.keys(), + table, + default_selection=effective_default, + console=self.console, + session=self.session, + prompt_message=self.prompt_message, + show_table=self.show_table, + ) + else: + key = effective_default + result = key if self.return_key else self.selections[key][1] + else: + raise TypeError( + f"'selections' must be a list[str] or dict[str, tuple[str, Any]], got {type(self.selections).__name__}" + ) + context.result = result + await self.hooks.trigger(HookType.ON_SUCCESS, context) + return result + except Exception as error: + context.exception = error + await self.hooks.trigger(HookType.ON_ERROR, context) + raise + finally: + context.stop_timer() + await self.hooks.trigger(HookType.AFTER, context) + await self.hooks.trigger(HookType.ON_TEARDOWN, context) + er.record(context) + + async def preview(self, parent: Tree | None = None): + label = f"[{OneColors.LIGHT_RED}]🧭 SelectionAction[/] '{self.name}'" + tree = parent.add(label) if parent else Tree(label) + + if isinstance(self.selections, list): + sub = tree.add(f"[dim]Type:[/] List[str] ({len(self.selections)} items)") + for i, item in enumerate(self.selections[:10]): # limit to 10 + sub.add(f"[dim]{i}[/]: {item}") + if len(self.selections) > 10: + sub.add(f"[dim]... ({len(self.selections) - 10} more)[/]") + elif isinstance(self.selections, dict): + sub = tree.add( + f"[dim]Type:[/] Dict[str, (str, Any)] ({len(self.selections)} items)" + ) + for i, (key, (label, _)) in enumerate(list(self.selections.items())[:10]): + sub.add(f"[dim]{key}[/]: {label}") + if len(self.selections) > 10: + sub.add(f"[dim]... ({len(self.selections) - 10} more)[/]") + else: + tree.add("[bold red]Invalid selections type[/]") + return + + tree.add(f"[dim]Default:[/] '{self.default_selection or self.last_result}'") + tree.add(f"[dim]Return:[/] {'Key' if self.return_key else 'Value'}") + tree.add(f"[dim]Prompt:[/] {'Disabled' if self.never_prompt else 'Enabled'}") + + if not parent: + self.console.print(tree) diff --git a/falyx/signal_action.py b/falyx/signal_action.py new file mode 100644 index 0000000..cf14bcf --- /dev/null +++ b/falyx/signal_action.py @@ -0,0 +1,29 @@ +from falyx.action import Action +from falyx.signals import FlowSignal + + +class SignalAction(Action): + """ + An action that raises a control flow signal when executed. + + Useful for exiting a menu, going back, or halting execution gracefully. + """ + + def __init__(self, name: str, signal: Exception): + if not isinstance(signal, FlowSignal): + raise TypeError( + f"Signal must be an FlowSignal instance, got {type(signal).__name__}" + ) + + async def raise_signal(*args, **kwargs): + raise signal + + super().__init__(name=name, action=raise_signal) + self._signal = signal + + @property + def signal(self): + return self._signal + + def __str__(self): + return f"SignalAction(name={self.name}, signal={self._signal.__class__.__name__})" diff --git a/falyx/signals.py b/falyx/signals.py new file mode 100644 index 0000000..1e55488 --- /dev/null +++ b/falyx/signals.py @@ -0,0 +1,21 @@ +# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed +class FlowSignal(BaseException): + """Base class for all flow control signals in Falyx. + + These are not errors. They're used to control flow like quitting, + going back, or restarting from user input or nested menus. + """ + + +class QuitSignal(FlowSignal): + """Raised to signal an immediate exit from the CLI framework.""" + + def __init__(self, message: str = "Quit signal received."): + super().__init__(message) + + +class BackSignal(FlowSignal): + """Raised to return control to the previous menu or caller.""" + + def __init__(self, message: str = "Back signal received."): + super().__init__(message) diff --git a/falyx/tagged_table.py b/falyx/tagged_table.py index 83637c6..a3bd21a 100644 --- a/falyx/tagged_table.py +++ b/falyx/tagged_table.py @@ -1,3 +1,4 @@ +# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed from collections import defaultdict from rich import box diff --git a/falyx/utils.py b/falyx/utils.py index b9a8619..7c57493 100644 --- a/falyx/utils.py +++ b/falyx/utils.py @@ -69,7 +69,7 @@ def chunks(iterator, size): yield chunk -async def async_confirm(message: AnyFormattedText = "Are you sure?") -> bool: +async def confirm_async(message: AnyFormattedText = "Are you sure?") -> bool: session: PromptSession = PromptSession() while True: merged_message: AnyFormattedText = merge_formatted_text( @@ -86,26 +86,36 @@ async def async_confirm(message: AnyFormattedText = "Are you sure?") -> bool: class CaseInsensitiveDict(dict): """A case-insensitive dictionary that treats all keys as uppercase.""" + def _normalize_key(self, key): + return key.upper() if isinstance(key, str) else key + def __setitem__(self, key, value): - super().__setitem__(key.upper(), value) + super().__setitem__(self._normalize_key(key), value) def __getitem__(self, key): - return super().__getitem__(key.upper()) + return super().__getitem__(self._normalize_key(key)) def __contains__(self, key): - return super().__contains__(key.upper()) + return super().__contains__(self._normalize_key(key)) def get(self, key, default=None): - return super().get(key.upper(), default) + return super().get(self._normalize_key(key), default) def pop(self, key, default=None): - return super().pop(key.upper(), default) + return super().pop(self._normalize_key(key), default) def update(self, other=None, **kwargs): + items = {} if other: - other = {k.upper(): v for k, v in other.items()} - kwargs = {k.upper(): v for k, v in kwargs.items()} - super().update(other, **kwargs) + items.update({self._normalize_key(k): v for k, v in other.items()}) + items.update({self._normalize_key(k): v for k, v in kwargs.items()}) + super().update(items) + + def __iter__(self): + return super().__iter__() + + def keys(self): + return super().keys() def running_in_container() -> bool: diff --git a/falyx/validators.py b/falyx/validators.py new file mode 100644 index 0000000..68eaf96 --- /dev/null +++ b/falyx/validators.py @@ -0,0 +1,30 @@ +# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed +from typing import KeysView, Sequence + +from prompt_toolkit.validation import Validator + + +def int_range_validator(minimum: int, maximum: int) -> Validator: + """Validator for integer ranges.""" + + def validate(input: str) -> bool: + try: + value = int(input) + if not (minimum <= value <= maximum): + return False + return True + except ValueError: + return False + + return Validator.from_callable(validate, error_message="Invalid input.") + + +def key_validator(keys: Sequence[str] | KeysView[str]) -> Validator: + """Validator for key inputs.""" + + def validate(input: str) -> bool: + if input.upper() not in [key.upper() for key in keys]: + return False + return True + + return Validator.from_callable(validate, error_message="Invalid input.") diff --git a/falyx/version.py b/falyx/version.py index 569b121..0c5c300 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.10" +__version__ = "0.1.11" diff --git a/pyproject.toml b/pyproject.toml index 011022d..4efe864 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "falyx" -version = "0.1.10" +version = "0.1.11" description = "Reliable and introspectable async CLI action framework." authors = ["Roland Thomas Jr "] license = "MIT" @@ -23,7 +23,6 @@ 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] falyx = "falyx.__main__:main" diff --git a/tests/test_action_basic.py b/tests/test_action_basic.py index 2f4ff14..9745b7a 100644 --- a/tests/test_action_basic.py +++ b/tests/test_action_basic.py @@ -42,6 +42,48 @@ async def test_action_async_callable(): str(action) == "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)" ) + assert ( + repr(action) + == "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)" + ) + + +@pytest.mark.asyncio +async def test_chained_action(): + """Test if ChainedAction can be created and used.""" + action1 = Action("one", lambda: 1) + action2 = Action("two", lambda: 2) + chain = ChainedAction( + name="Simple Chain", + actions=[action1, action2], + return_list=True, + ) + + result = await chain() + assert result == [1, 2] + assert ( + str(chain) + == "ChainedAction(name='Simple Chain', actions=['one', 'two'], auto_inject=False, return_list=True)" + ) + + +@pytest.mark.asyncio +async def test_action_group(): + """Test if ActionGroup can be created and used.""" + action1 = Action("one", lambda: 1) + action2 = Action("two", lambda: 2) + group = ChainedAction( + name="Simple Group", + actions=[action1, action2], + return_list=True, + ) + + result = await group() + assert result == [1, 2] + assert ( + str(group) + == "ChainedAction(name='Simple Group', actions=['one', 'two'], auto_inject=False, return_list=True)" + ) @pytest.mark.asyncio @@ -120,3 +162,62 @@ async def test_fallback_action(): result = await chain() assert result == "Fallback value" assert str(action) == "FallbackAction(fallback='Fallback value')" + + +@pytest.mark.asyncio +async def test_remove_action_from_chain(): + """Test if an action can be removed from a chain.""" + action1 = Action(name="one", action=lambda: 1) + action2 = Action(name="two", action=lambda: 2) + chain = ChainedAction( + name="Simple Chain", + actions=[action1, action2], + ) + + assert len(chain.actions) == 2 + + # Remove the first action + chain.remove_action(action1.name) + + assert len(chain.actions) == 1 + assert chain.actions[0] == action2 + + +@pytest.mark.asyncio +async def test_has_action_in_chain(): + """Test if an action can be checked for presence in a chain.""" + action1 = Action(name="one", action=lambda: 1) + action2 = Action(name="two", action=lambda: 2) + chain = ChainedAction( + name="Simple Chain", + actions=[action1, action2], + ) + + assert chain.has_action(action1.name) is True + assert chain.has_action(action2.name) is True + + # Remove the first action + chain.remove_action(action1.name) + + assert chain.has_action(action1.name) is False + assert chain.has_action(action2.name) is True + + +@pytest.mark.asyncio +async def test_get_action_from_chain(): + """Test if an action can be retrieved from a chain.""" + action1 = Action(name="one", action=lambda: 1) + action2 = Action(name="two", action=lambda: 2) + chain = ChainedAction( + name="Simple Chain", + actions=[action1, action2], + ) + + assert chain.get_action(action1.name) == action1 + assert chain.get_action(action2.name) == action2 + + # Remove the first action + chain.remove_action(action1.name) + + assert chain.get_action(action1.name) is None + assert chain.get_action(action2.name) == action2 diff --git a/tests/test_headless.py b/tests/test_headless.py new file mode 100644 index 0000000..c8feb72 --- /dev/null +++ b/tests/test_headless.py @@ -0,0 +1,45 @@ +import pytest + +from falyx import Action, Falyx + + +@pytest.mark.asyncio +async def test_headless(): + """Test if Falyx can run in headless mode.""" + falyx = Falyx("Headless Test") + + # Add a simple command + falyx.add_command( + key="T", + description="Test Command", + action=lambda: "Hello, World!", + ) + + # Run the CLI + result = await falyx.headless("T") + assert result == "Hello, World!" + + +@pytest.mark.asyncio +async def test_headless_recovery(): + """Test if Falyx can recover from a failure in headless mode.""" + falyx = Falyx("Headless Recovery Test") + + state = {"count": 0} + + async def flaky(): + if not state["count"]: + state["count"] += 1 + raise RuntimeError("Random failure!") + return "ok" + + # Add a command that raises an exception + falyx.add_command( + key="E", + description="Error Command", + action=Action("flaky", flaky), + retry=True, + ) + + result = await falyx.headless("E") + assert result == "ok"