From b14004c9898adf4151f099c065474747a3c059b5 Mon Sep 17 00:00:00 2001 From: Roland Thomas Date: Wed, 14 May 2025 20:28:28 -0400 Subject: [PATCH] Add UserInputAction, coerce ActionFactoryAction to be async, add custom tables for MenuAction, Change Exit Command to use X --- examples/falyx.yaml | 1 + examples/user_input_demo.py | 38 +++++++++++++ falyx/action/__init__.py | 2 + falyx/action/action_factory.py | 13 ++++- falyx/action/menu_action.py | 4 ++ falyx/action/user_input_action.py | 94 +++++++++++++++++++++++++++++++ falyx/command.py | 8 +-- falyx/execution_registry.py | 2 +- falyx/falyx.py | 24 ++++---- falyx/protocols.py | 4 +- falyx/signals.py | 7 +++ falyx/utils.py | 1 + falyx/version.py | 2 +- pyproject.toml | 2 +- 14 files changed, 181 insertions(+), 21 deletions(-) create mode 100644 examples/user_input_demo.py create mode 100644 falyx/action/user_input_action.py diff --git a/examples/falyx.yaml b/examples/falyx.yaml index 85727f0..6462df3 100644 --- a/examples/falyx.yaml +++ b/examples/falyx.yaml @@ -9,6 +9,7 @@ commands: description: Run HTTP Action Group action: http_demo.action_group tags: [http, demo] + confirm: true - key: S description: Select a file diff --git a/examples/user_input_demo.py b/examples/user_input_demo.py new file mode 100644 index 0000000..2137c98 --- /dev/null +++ b/examples/user_input_demo.py @@ -0,0 +1,38 @@ +import asyncio + +from prompt_toolkit.validation import Validator + +from falyx.action import Action, ChainedAction, UserInputAction + + +def validate_alpha() -> Validator: + def validate(text: str) -> bool: + return text.isalpha() + + return Validator.from_callable( + validate, + error_message="Please enter only alphabetic characters.", + move_cursor_to_end=True, + ) + + +chain = ChainedAction( + name="Demo Chain", + actions=[ + "Name", + UserInputAction( + name="User Input", + prompt_text="Enter your {last_result}: ", + validator=validate_alpha(), + ), + Action( + name="Display Name", + action=lambda last_result: print(f"Hello, {last_result}!"), + ), + ], + auto_inject=True, +) + +if __name__ == "__main__": + asyncio.run(chain.preview()) + asyncio.run(chain()) diff --git a/falyx/action/__init__.py b/falyx/action/__init__.py index 6f62c54..d480608 100644 --- a/falyx/action/__init__.py +++ b/falyx/action/__init__.py @@ -21,6 +21,7 @@ from .menu_action import MenuAction from .select_file_action import SelectFileAction from .selection_action import SelectionAction from .signal_action import SignalAction +from .user_input_action import UserInputAction __all__ = [ "Action", @@ -38,4 +39,5 @@ __all__ = [ "SignalAction", "FallbackAction", "LiteralInputAction", + "UserInputAction", ] diff --git a/falyx/action/action_factory.py b/falyx/action/action_factory.py index 8232d9e..724bd11 100644 --- a/falyx/action/action_factory.py +++ b/falyx/action/action_factory.py @@ -11,6 +11,7 @@ from falyx.hook_manager import HookType from falyx.logger import logger from falyx.protocols import ActionFactoryProtocol from falyx.themes import OneColors +from falyx.utils import ensure_async class ActionFactoryAction(BaseAction): @@ -46,6 +47,14 @@ class ActionFactoryAction(BaseAction): self.preview_args = preview_args self.preview_kwargs = preview_kwargs or {} + @property + def factory(self) -> ActionFactoryProtocol: + return self._factory # type: ignore[return-value] + + @factory.setter + def factory(self, value: ActionFactoryProtocol): + self._factory = ensure_async(value) + async def _run(self, *args, **kwargs) -> Any: updated_kwargs = self._maybe_inject_last_result(kwargs) context = ExecutionContext( @@ -57,7 +66,7 @@ class ActionFactoryAction(BaseAction): context.start_timer() try: await self.hooks.trigger(HookType.BEFORE, context) - generated_action = self.factory(*args, **updated_kwargs) + generated_action = await self.factory(*args, **updated_kwargs) if not isinstance(generated_action, BaseAction): raise TypeError( f"[{self.name}] Factory must return a BaseAction, got " @@ -94,7 +103,7 @@ class ActionFactoryAction(BaseAction): tree = parent.add(label) if parent else Tree(label) try: - generated = self.factory(*self.preview_args, **self.preview_kwargs) + generated = await self.factory(*self.preview_args, **self.preview_kwargs) if isinstance(generated, BaseAction): await generated.preview(parent=tree) else: diff --git a/falyx/action/menu_action.py b/falyx/action/menu_action.py index b1f47a6..4ff6179 100644 --- a/falyx/action/menu_action.py +++ b/falyx/action/menu_action.py @@ -38,6 +38,7 @@ class MenuAction(BaseAction): never_prompt: bool = False, include_reserved: bool = True, show_table: bool = True, + custom_table: Table | None = None, ): super().__init__( name, @@ -54,8 +55,11 @@ class MenuAction(BaseAction): self.prompt_session = prompt_session or PromptSession() self.include_reserved = include_reserved self.show_table = show_table + self.custom_table = custom_table def _build_table(self) -> Table: + if self.custom_table: + return self.custom_table table = render_table_base( title=self.title, columns=self.columns, diff --git a/falyx/action/user_input_action.py b/falyx/action/user_input_action.py new file mode 100644 index 0000000..47ee81a --- /dev/null +++ b/falyx/action/user_input_action.py @@ -0,0 +1,94 @@ +from prompt_toolkit import PromptSession +from prompt_toolkit.validation import Validator +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.themes.colors import OneColors + + +class UserInputAction(BaseAction): + """ + Prompts the user for input via PromptSession and returns the result. + + Args: + name (str): Action name. + prompt_text (str): Prompt text (can include '{last_result}' for interpolation). + validator (Validator, optional): Prompt Toolkit validator. + console (Console, optional): Rich console for rendering. + prompt_session (PromptSession, optional): Reusable prompt session. + inject_last_result (bool): Whether to inject last_result into prompt. + inject_into (str): Key to use for injection (default: 'last_result'). + """ + + def __init__( + self, + name: str, + *, + prompt_text: str = "Input > ", + validator: Validator | None = None, + console: Console | None = None, + prompt_session: PromptSession | None = None, + inject_last_result: bool = False, + ): + super().__init__( + name=name, + inject_last_result=inject_last_result, + ) + self.prompt_text = prompt_text + self.validator = validator + self.console = console or Console(color_system="auto") + self.prompt_session = prompt_session or PromptSession() + + async def _run(self, *args, **kwargs) -> str: + context = ExecutionContext( + name=self.name, + args=args, + kwargs=kwargs, + action=self, + ) + context.start_timer() + try: + await self.hooks.trigger(HookType.BEFORE, context) + + prompt_text = self.prompt_text + if self.inject_last_result and self.last_result: + prompt_text = prompt_text.format(last_result=self.last_result) + + answer = await self.prompt_session.prompt_async( + prompt_text, + validator=self.validator, + ) + context.result = answer + await self.hooks.trigger(HookType.ON_SUCCESS, context) + return answer + 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.MAGENTA}]⌨ UserInputAction[/] '{self.name}'" + tree = parent.add(label) if parent else Tree(label) + + prompt_text = ( + self.prompt_text.replace("{last_result}", "") + if "{last_result}" in self.prompt_text + else self.prompt_text + ) + tree.add(f"[dim]Prompt:[/] {prompt_text}") + if self.validator: + tree.add("[dim]Validator:[/] Yes") + if not parent: + self.console.print(tree) + + def __str__(self): + return f"UserInputAction(name={self.name!r}, prompt={self.prompt!r})" diff --git a/falyx/command.py b/falyx/command.py index 0933d57..80fbcce 100644 --- a/falyx/command.py +++ b/falyx/command.py @@ -30,7 +30,6 @@ from falyx.action.action import Action, ActionGroup, BaseAction, ChainedAction from falyx.action.io_action import BaseIOAction from falyx.context import ExecutionContext from falyx.debug import register_debug_hooks -from falyx.exceptions import FalyxError from falyx.execution_registry import ExecutionRegistry as er from falyx.hook_manager import HookManager, HookType from falyx.logger import logger @@ -38,8 +37,9 @@ from falyx.options_manager import OptionsManager from falyx.prompt_utils import confirm_async, should_prompt_user from falyx.retry import RetryPolicy from falyx.retry_utils import enable_retries_recursively +from falyx.signals import CancelSignal from falyx.themes import OneColors -from falyx.utils import _noop, ensure_async +from falyx.utils import ensure_async console = Console(color_system="auto") @@ -98,7 +98,7 @@ class Command(BaseModel): key: str description: str - action: BaseAction | Callable[[], Any] = _noop + action: BaseAction | Callable[[], Any] args: tuple = () kwargs: dict[str, Any] = Field(default_factory=dict) hidden: bool = False @@ -205,7 +205,7 @@ class Command(BaseModel): await self.preview() if not await confirm_async(self.confirmation_prompt): logger.info("[Command:%s] ❌ Cancelled by user.", self.key) - raise FalyxError(f"[Command:{self.key}] Cancelled by confirmation.") + raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.") context.start_timer() diff --git a/falyx/execution_registry.py b/falyx/execution_registry.py index b5d0a7b..60b55e0 100644 --- a/falyx/execution_registry.py +++ b/falyx/execution_registry.py @@ -100,7 +100,7 @@ class ExecutionRegistry: @classmethod def summary(cls): - table = Table(title="[📊] Execution History", expand=True, box=box.SIMPLE) + table = Table(title="📊 Execution History", expand=True, box=box.SIMPLE) table.add_column("Name", style="bold cyan") table.add_column("Start", justify="right", style="dim") diff --git a/falyx/falyx.py b/falyx/falyx.py index caf38e0..bd66143 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -57,9 +57,9 @@ from falyx.logger import logger 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.signals import BackSignal, CancelSignal, QuitSignal from falyx.themes import OneColors, get_nord_theme -from falyx.utils import CaseInsensitiveDict, chunks, get_program_invocation +from falyx.utils import CaseInsensitiveDict, _noop, chunks, get_program_invocation from falyx.version import __version__ @@ -237,8 +237,9 @@ class Falyx: def _get_exit_command(self) -> Command: """Returns the back command for the menu.""" return Command( - key="Q", + key="X", description="Exit", + action=Action("Exit", action=_noop), aliases=["EXIT", "QUIT"], style=OneColors.DARK_RED, ) @@ -266,9 +267,9 @@ class Falyx: help_text += " [dim](requires input)[/dim]" table.add_row( f"[{command.style}]{command.key}[/]", - ", ".join(command.aliases) if command.aliases else "None", + ", ".join(command.aliases) if command.aliases else "", help_text, - ", ".join(command.tags) if command.tags else "None", + ", ".join(command.tags) if command.tags else "", ) table.add_row( @@ -305,7 +306,7 @@ class Falyx: key="H", aliases=["HELP", "?"], description="Help", - action=self._show_help, + action=Action("Help", self._show_help), style=OneColors.LIGHT_YELLOW, ) @@ -507,18 +508,19 @@ class Falyx: def update_exit_command( self, - key: str = "Q", + key: str = "X", description: str = "Exit", aliases: list[str] | None = None, - action: Callable[[], Any] = lambda: None, + action: Callable[[], Any] | None = None, style: str = OneColors.DARK_RED, confirm: bool = False, confirm_message: str = "Are you sure?", ) -> None: """Updates the back command of the menu.""" + self._validate_command_key(key) + action = action or Action(description, action=_noop) if not callable(action): raise InvalidActionError("Action must be a callable.") - self._validate_command_key(key) self.exit_command = Command( key=key, description=description, @@ -537,7 +539,7 @@ class Falyx: raise NotAFalyxError("submenu must be an instance of Falyx.") self._validate_command_key(key) self.add_command(key, description, submenu.menu, style=style) - if submenu.exit_command.key == "Q": + if submenu.exit_command.key == "X": submenu.update_exit_command(key="B", description="Back", aliases=["BACK"]) def add_commands(self, commands: list[Command] | list[dict]) -> None: @@ -918,6 +920,8 @@ class Falyx: break except BackSignal: logger.info("BackSignal received.") + except CancelSignal: + logger.info("CancelSignal received.") finally: logger.info("Exiting menu: %s", self.get_title()) if self.exit_message: diff --git a/falyx/protocols.py b/falyx/protocols.py index a9b1cc5..81a0f7b 100644 --- a/falyx/protocols.py +++ b/falyx/protocols.py @@ -2,10 +2,10 @@ """protocols.py""" from __future__ import annotations -from typing import Any, Protocol +from typing import Any, Awaitable, Protocol from falyx.action.action import BaseAction class ActionFactoryProtocol(Protocol): - def __call__(self, *args: Any, **kwargs: Any) -> BaseAction: ... + async def __call__(self, *args: Any, **kwargs: Any) -> Awaitable[BaseAction]: ... diff --git a/falyx/signals.py b/falyx/signals.py index 256c052..ef6b6b2 100644 --- a/falyx/signals.py +++ b/falyx/signals.py @@ -22,3 +22,10 @@ class BackSignal(FlowSignal): def __init__(self, message: str = "Back signal received."): super().__init__(message) + + +class CancelSignal(FlowSignal): + """Raised to cancel the current command or action.""" + + def __init__(self, message: str = "Cancel signal received."): + super().__init__(message) diff --git a/falyx/utils.py b/falyx/utils.py index 3ad746f..4e7c0ea 100644 --- a/falyx/utils.py +++ b/falyx/utils.py @@ -48,6 +48,7 @@ def ensure_async(function: Callable[..., T]) -> Callable[..., Awaitable[T]]: if not callable(function): raise TypeError(f"{function} is not callable") + return async_wrapper diff --git a/falyx/version.py b/falyx/version.py index c8ec146..19492a9 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.26" +__version__ = "0.1.27" diff --git a/pyproject.toml b/pyproject.toml index 457df1c..748127f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "falyx" -version = "0.1.26" +version = "0.1.27" description = "Reliable and introspectable async CLI action framework." authors = ["Roland Thomas Jr "] license = "MIT"