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 description: Run HTTP Action Group
action: http_demo.action_group action: http_demo.action_group
tags: [http, demo] tags: [http, demo]
confirm: true
- key: S - key: S
description: Select a file 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 .select_file_action import SelectFileAction
from .selection_action import SelectionAction from .selection_action import SelectionAction
from .signal_action import SignalAction from .signal_action import SignalAction
from .user_input_action import UserInputAction
__all__ = [ __all__ = [
"Action", "Action",
@ -38,4 +39,5 @@ __all__ = [
"SignalAction", "SignalAction",
"FallbackAction", "FallbackAction",
"LiteralInputAction", "LiteralInputAction",
"UserInputAction",
] ]

View File

@ -11,6 +11,7 @@ from falyx.hook_manager import HookType
from falyx.logger import logger from falyx.logger import logger
from falyx.protocols import ActionFactoryProtocol from falyx.protocols import ActionFactoryProtocol
from falyx.themes import OneColors from falyx.themes import OneColors
from falyx.utils import ensure_async
class ActionFactoryAction(BaseAction): class ActionFactoryAction(BaseAction):
@ -46,6 +47,14 @@ class ActionFactoryAction(BaseAction):
self.preview_args = preview_args self.preview_args = preview_args
self.preview_kwargs = preview_kwargs or {} 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: async def _run(self, *args, **kwargs) -> Any:
updated_kwargs = self._maybe_inject_last_result(kwargs) updated_kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext( context = ExecutionContext(
@ -57,7 +66,7 @@ class ActionFactoryAction(BaseAction):
context.start_timer() context.start_timer()
try: try:
await self.hooks.trigger(HookType.BEFORE, context) 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): if not isinstance(generated_action, BaseAction):
raise TypeError( raise TypeError(
f"[{self.name}] Factory must return a BaseAction, got " 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) tree = parent.add(label) if parent else Tree(label)
try: try:
generated = self.factory(*self.preview_args, **self.preview_kwargs) generated = await self.factory(*self.preview_args, **self.preview_kwargs)
if isinstance(generated, BaseAction): if isinstance(generated, BaseAction):
await generated.preview(parent=tree) await generated.preview(parent=tree)
else: else:

View File

@ -38,6 +38,7 @@ class MenuAction(BaseAction):
never_prompt: bool = False, never_prompt: bool = False,
include_reserved: bool = True, include_reserved: bool = True,
show_table: bool = True, show_table: bool = True,
custom_table: Table | None = None,
): ):
super().__init__( super().__init__(
name, name,
@ -54,8 +55,11 @@ class MenuAction(BaseAction):
self.prompt_session = prompt_session or PromptSession() self.prompt_session = prompt_session or PromptSession()
self.include_reserved = include_reserved self.include_reserved = include_reserved
self.show_table = show_table self.show_table = show_table
self.custom_table = custom_table
def _build_table(self) -> Table: def _build_table(self) -> Table:
if self.custom_table:
return self.custom_table
table = render_table_base( table = render_table_base(
title=self.title, title=self.title,
columns=self.columns, 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.action.io_action import BaseIOAction
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.debug import register_debug_hooks from falyx.debug import register_debug_hooks
from falyx.exceptions import FalyxError
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType from falyx.hook_manager import HookManager, HookType
from falyx.logger import logger 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.prompt_utils import confirm_async, should_prompt_user
from falyx.retry import RetryPolicy from falyx.retry import RetryPolicy
from falyx.retry_utils import enable_retries_recursively from falyx.retry_utils import enable_retries_recursively
from falyx.signals import CancelSignal
from falyx.themes import OneColors from falyx.themes import OneColors
from falyx.utils import _noop, ensure_async from falyx.utils import ensure_async
console = Console(color_system="auto") console = Console(color_system="auto")
@ -98,7 +98,7 @@ class Command(BaseModel):
key: str key: str
description: str description: str
action: BaseAction | Callable[[], Any] = _noop action: BaseAction | Callable[[], Any]
args: tuple = () args: tuple = ()
kwargs: dict[str, Any] = Field(default_factory=dict) kwargs: dict[str, Any] = Field(default_factory=dict)
hidden: bool = False hidden: bool = False
@ -205,7 +205,7 @@ class Command(BaseModel):
await self.preview() await self.preview()
if not await confirm_async(self.confirmation_prompt): if not await confirm_async(self.confirmation_prompt):
logger.info("[Command:%s] ❌ Cancelled by user.", self.key) 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() context.start_timer()

View File

@ -100,7 +100,7 @@ class ExecutionRegistry:
@classmethod @classmethod
def summary(cls): 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("Name", style="bold cyan")
table.add_column("Start", justify="right", style="dim") 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.options_manager import OptionsManager
from falyx.parsers import get_arg_parsers from falyx.parsers import get_arg_parsers
from falyx.retry import RetryPolicy 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.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__ from falyx.version import __version__
@ -237,8 +237,9 @@ class Falyx:
def _get_exit_command(self) -> Command: def _get_exit_command(self) -> Command:
"""Returns the back command for the menu.""" """Returns the back command for the menu."""
return Command( return Command(
key="Q", key="X",
description="Exit", description="Exit",
action=Action("Exit", action=_noop),
aliases=["EXIT", "QUIT"], aliases=["EXIT", "QUIT"],
style=OneColors.DARK_RED, style=OneColors.DARK_RED,
) )
@ -266,9 +267,9 @@ class Falyx:
help_text += " [dim](requires input)[/dim]" help_text += " [dim](requires input)[/dim]"
table.add_row( table.add_row(
f"[{command.style}]{command.key}[/]", f"[{command.style}]{command.key}[/]",
", ".join(command.aliases) if command.aliases else "None", ", ".join(command.aliases) if command.aliases else "",
help_text, help_text,
", ".join(command.tags) if command.tags else "None", ", ".join(command.tags) if command.tags else "",
) )
table.add_row( table.add_row(
@ -305,7 +306,7 @@ class Falyx:
key="H", key="H",
aliases=["HELP", "?"], aliases=["HELP", "?"],
description="Help", description="Help",
action=self._show_help, action=Action("Help", self._show_help),
style=OneColors.LIGHT_YELLOW, style=OneColors.LIGHT_YELLOW,
) )
@ -507,18 +508,19 @@ class Falyx:
def update_exit_command( def update_exit_command(
self, self,
key: str = "Q", key: str = "X",
description: str = "Exit", description: str = "Exit",
aliases: list[str] | None = None, aliases: list[str] | None = None,
action: Callable[[], Any] = lambda: None, action: Callable[[], Any] | None = None,
style: str = OneColors.DARK_RED, style: str = OneColors.DARK_RED,
confirm: bool = False, confirm: bool = False,
confirm_message: str = "Are you sure?", confirm_message: str = "Are you sure?",
) -> None: ) -> None:
"""Updates the back command of the menu.""" """Updates the back command of the menu."""
self._validate_command_key(key)
action = action or Action(description, action=_noop)
if not callable(action): if not callable(action):
raise InvalidActionError("Action must be a callable.") raise InvalidActionError("Action must be a callable.")
self._validate_command_key(key)
self.exit_command = Command( self.exit_command = Command(
key=key, key=key,
description=description, description=description,
@ -537,7 +539,7 @@ class Falyx:
raise NotAFalyxError("submenu must be an instance of Falyx.") raise NotAFalyxError("submenu must be an instance of Falyx.")
self._validate_command_key(key) self._validate_command_key(key)
self.add_command(key, description, submenu.menu, style=style) 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"]) submenu.update_exit_command(key="B", description="Back", aliases=["BACK"])
def add_commands(self, commands: list[Command] | list[dict]) -> None: def add_commands(self, commands: list[Command] | list[dict]) -> None:
@ -918,6 +920,8 @@ class Falyx:
break break
except BackSignal: except BackSignal:
logger.info("BackSignal received.") logger.info("BackSignal received.")
except CancelSignal:
logger.info("CancelSignal received.")
finally: finally:
logger.info("Exiting menu: %s", self.get_title()) logger.info("Exiting menu: %s", self.get_title())
if self.exit_message: if self.exit_message:

View File

@ -2,10 +2,10 @@
"""protocols.py""" """protocols.py"""
from __future__ import annotations from __future__ import annotations
from typing import Any, Protocol from typing import Any, Awaitable, Protocol
from falyx.action.action import BaseAction from falyx.action.action import BaseAction
class ActionFactoryProtocol(Protocol): 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."): def __init__(self, message: str = "Back signal received."):
super().__init__(message) 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): if not callable(function):
raise TypeError(f"{function} is not callable") raise TypeError(f"{function} is not callable")
return async_wrapper return async_wrapper

View File

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

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "falyx" name = "falyx"
version = "0.1.26" version = "0.1.27"
description = "Reliable and introspectable async CLI action framework." description = "Reliable and introspectable async CLI action framework."
authors = ["Roland Thomas Jr <roland@rtj.dev>"] authors = ["Roland Thomas Jr <roland@rtj.dev>"]
license = "MIT" license = "MIT"