Add UserInputAction, coerce ActionFactoryAction to be async, add custom tables for MenuAction, Change Exit Command to use X

This commit is contained in:
Roland Thomas Jr 2025-05-14 20:28:28 -04:00
parent bba473047c
commit b14004c989
Signed by: roland
GPG Key ID: 7C3C2B085A4C2872
14 changed files with 181 additions and 21 deletions

View File

@ -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

View File

@ -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())

View File

@ -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",
]

View File

@ -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:

View File

@ -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,

View File

@ -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}", "<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})"

View File

@ -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()

View File

@ -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")

View File

@ -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:

View File

@ -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]: ...

View File

@ -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)

View File

@ -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

View File

@ -1 +1 @@
__version__ = "0.1.26"
__version__ = "0.1.27"

View File

@ -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 <roland@rtj.dev>"]
license = "MIT"