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
|
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
|
||||||
|
|
|
@ -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 .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",
|
||||||
]
|
]
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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.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()
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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]: ...
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
__version__ = "0.1.26"
|
__version__ = "0.1.27"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue