Add UserInputAction, coerce ActionFactoryAction to be async, add custom tables for MenuAction, Change Exit Command to use X
This commit is contained in:
parent
bba473047c
commit
b14004c989
|
@ -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
|
||||
|
|
|
@ -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())
|
|
@ -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",
|
||||
]
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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})"
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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]: ...
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -1 +1 @@
|
|||
__version__ = "0.1.26"
|
||||
__version__ = "0.1.27"
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue