Add multi selecto to SelectionAction and SelectFileAction, Allow IOActions to receive no input, Rename subpackage falyx.parsers -> falyx.parser, Add default_text to UserInputAction

This commit is contained in:
2025-06-08 12:09:16 -04:00
parent b24079ea7e
commit 53ba6a896a
36 changed files with 373 additions and 80 deletions

View File

@ -6,11 +6,12 @@ from falyx.action.types import FileReturnType
sf = SelectFileAction( sf = SelectFileAction(
name="select_file", name="select_file",
suffix_filter=".py", suffix_filter=".yaml",
title="Select a YAML file", title="Select a YAML file",
prompt_message="Choose > ", prompt_message="Choose 2 > ",
return_type=FileReturnType.TEXT, return_type=FileReturnType.TEXT,
columns=3, columns=3,
number_selections=2,
) )
flx = Falyx() flx = Falyx()

View File

@ -1,5 +1,7 @@
import asyncio import asyncio
from uuid import uuid4
from falyx import Falyx
from falyx.action import SelectionAction from falyx.action import SelectionAction
from falyx.selection import SelectionOption from falyx.selection import SelectionOption
from falyx.signals import CancelSignal from falyx.signals import CancelSignal
@ -24,7 +26,45 @@ select = SelectionAction(
show_table=True, show_table=True,
) )
try: list_selections = [uuid4() for _ in range(10)]
print(asyncio.run(select()))
except CancelSignal: list_select = SelectionAction(
print("Selection was cancelled.") name="Select Deployments",
selections=list_selections,
title="Select Deployments",
columns=3,
prompt_message="Select 3 Deployments > ",
return_type="value",
show_table=True,
number_selections=3,
)
flx = Falyx()
flx.add_command(
key="S",
description="Select a deployment",
action=select,
help_text="Select a deployment from the list",
)
flx.add_command(
key="L",
description="Select deployments",
action=list_select,
help_text="Select multiple deployments from the list",
)
if __name__ == "__main__":
try:
print(asyncio.run(select()))
except CancelSignal:
print("Selection was cancelled.")
try:
print(asyncio.run(list_select()))
except CancelSignal:
print("Selection was cancelled.")
asyncio.run(flx.run())

View File

@ -2,7 +2,7 @@ import asyncio
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from falyx import Falyx from falyx import Falyx
from falyx.parsers import CommandArgumentParser from falyx.parser import CommandArgumentParser
flx = Falyx("Test Type Validation") flx = Falyx("Test Type Validation")

View File

@ -14,7 +14,7 @@ from typing import Any
from falyx.config import loader from falyx.config import loader
from falyx.falyx import Falyx from falyx.falyx import Falyx
from falyx.parsers import CommandArgumentParser, get_root_parser, get_subparsers from falyx.parser import CommandArgumentParser, get_root_parser, get_subparsers
def find_falyx_config() -> Path | None: def find_falyx_config() -> Path | None:

View File

@ -157,6 +157,6 @@ class Action(BaseAction):
return ( return (
f"Action(name={self.name!r}, action=" f"Action(name={self.name!r}, action="
f"{getattr(self._action, '__name__', repr(self._action))}, " f"{getattr(self._action, '__name__', repr(self._action))}, "
f"args={self.args!r}, kwargs={self.kwargs!r}, " f"retry={self.retry_policy.enabled}, "
f"retry={self.retry_policy.enabled})" f"rollback={self.rollback is not None})"
) )

View File

@ -11,7 +11,7 @@ from falyx.context import ExecutionContext, SharedContext
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import Hook, HookManager, HookType from falyx.hook_manager import Hook, HookManager, HookType
from falyx.logger import logger from falyx.logger import logger
from falyx.parsers.utils import same_argument_definitions from falyx.parser.utils import same_argument_definitions
from falyx.themes.colors import OneColors from falyx.themes.colors import OneColors

View File

@ -93,10 +93,7 @@ class BaseIOAction(BaseAction):
if self.inject_last_result and self.shared_context: if self.inject_last_result and self.shared_context:
return self.shared_context.last_result() return self.shared_context.last_result()
logger.debug( return ""
"[%s] No input provided and no last result found for injection.", self.name
)
raise FalyxError("No input provided and no last result to inject.")
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]: def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
return None, None return None, None

View File

@ -111,7 +111,7 @@ class MenuAction(BaseAction):
key = effective_default key = effective_default
if not self.never_prompt: if not self.never_prompt:
table = self._build_table() table = self._build_table()
key = await prompt_for_selection( key_ = await prompt_for_selection(
self.menu_options.keys(), self.menu_options.keys(),
table, table,
default_selection=self.default_selection, default_selection=self.default_selection,
@ -120,6 +120,10 @@ class MenuAction(BaseAction):
prompt_message=self.prompt_message, prompt_message=self.prompt_message,
show_table=self.show_table, show_table=self.show_table,
) )
if isinstance(key_, str):
key = key_
else:
assert False, "Unreachable, MenuAction only supports single selection"
option = self.menu_options[key] option = self.menu_options[key]
result = await option.action(*args, **kwargs) result = await option.action(*args, **kwargs)
context.result = result context.result = result

View File

@ -14,7 +14,7 @@ from falyx.context import ExecutionContext, SharedContext
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
from falyx.parsers.utils import same_argument_definitions from falyx.parser.utils import same_argument_definitions
from falyx.themes import OneColors from falyx.themes import OneColors

View File

@ -66,6 +66,9 @@ class SelectFileAction(BaseAction):
style: str = OneColors.WHITE, style: str = OneColors.WHITE,
suffix_filter: str | None = None, suffix_filter: str | None = None,
return_type: FileReturnType | str = FileReturnType.PATH, return_type: FileReturnType | str = FileReturnType.PATH,
number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
console: Console | None = None, console: Console | None = None,
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
): ):
@ -76,6 +79,9 @@ class SelectFileAction(BaseAction):
self.prompt_message = prompt_message self.prompt_message = prompt_message
self.suffix_filter = suffix_filter self.suffix_filter = suffix_filter
self.style = style self.style = style
self.number_selections = number_selections
self.separator = separator
self.allow_duplicates = allow_duplicates
if isinstance(console, Console): if isinstance(console, Console):
self.console = console self.console = console
elif console: elif console:
@ -83,6 +89,21 @@ class SelectFileAction(BaseAction):
self.prompt_session = prompt_session or PromptSession() self.prompt_session = prompt_session or PromptSession()
self.return_type = self._coerce_return_type(return_type) self.return_type = self._coerce_return_type(return_type)
@property
def number_selections(self) -> int | str:
return self._number_selections
@number_selections.setter
def number_selections(self, value: int | str):
if isinstance(value, int) and value > 0:
self._number_selections: int | str = value
elif isinstance(value, str):
if value not in ("*"):
raise ValueError("number_selections string must be one of '*'")
self._number_selections = value
else:
raise ValueError("number_selections must be a positive integer or one of '*'")
def _coerce_return_type(self, return_type: FileReturnType | str) -> FileReturnType: def _coerce_return_type(self, return_type: FileReturnType | str) -> FileReturnType:
if isinstance(return_type, FileReturnType): if isinstance(return_type, FileReturnType):
return return_type return return_type
@ -163,18 +184,25 @@ class SelectFileAction(BaseAction):
title=self.title, selections=options | cancel_option, columns=self.columns title=self.title, selections=options | cancel_option, columns=self.columns
) )
key = await prompt_for_selection( keys = await prompt_for_selection(
(options | cancel_option).keys(), (options | cancel_option).keys(),
table, table,
console=self.console, console=self.console,
prompt_session=self.prompt_session, prompt_session=self.prompt_session,
prompt_message=self.prompt_message, prompt_message=self.prompt_message,
number_selections=self.number_selections,
separator=self.separator,
allow_duplicates=self.allow_duplicates,
cancel_key=cancel_key,
) )
if key == cancel_key: if isinstance(keys, str):
raise CancelSignal("User canceled the selection.") if keys == cancel_key:
raise CancelSignal("User canceled the selection.")
result = options[keys].value
elif isinstance(keys, list):
result = [options[key].value for key in keys]
result = options[key].value
context.result = result context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context) await self.hooks.trigger(HookType.ON_SUCCESS, context)
return result return result

View File

@ -48,6 +48,9 @@ class SelectionAction(BaseAction):
columns: int = 5, columns: int = 5,
prompt_message: str = "Select > ", prompt_message: str = "Select > ",
default_selection: str = "", default_selection: str = "",
number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
inject_last_result: bool = False, inject_last_result: bool = False,
inject_into: str = "last_result", inject_into: str = "last_result",
return_type: SelectionReturnType | str = "value", return_type: SelectionReturnType | str = "value",
@ -73,9 +76,26 @@ class SelectionAction(BaseAction):
raise ValueError("`console` must be an instance of `rich.console.Console`") raise ValueError("`console` must be an instance of `rich.console.Console`")
self.prompt_session = prompt_session or PromptSession() self.prompt_session = prompt_session or PromptSession()
self.default_selection = default_selection self.default_selection = default_selection
self.number_selections = number_selections
self.separator = separator
self.allow_duplicates = allow_duplicates
self.prompt_message = prompt_message self.prompt_message = prompt_message
self.show_table = show_table self.show_table = show_table
self.cancel_key = self._find_cancel_key()
@property
def number_selections(self) -> int | str:
return self._number_selections
@number_selections.setter
def number_selections(self, value: int | str):
if isinstance(value, int) and value > 0:
self._number_selections: int | str = value
elif isinstance(value, str):
if value not in ("*"):
raise ValueError("number_selections string must be '*'")
self._number_selections = value
else:
raise ValueError("number_selections must be a positive integer or '*'")
def _coerce_return_type( def _coerce_return_type(
self, return_type: SelectionReturnType | str self, return_type: SelectionReturnType | str
@ -156,6 +176,38 @@ class SelectionAction(BaseAction):
def get_infer_target(self) -> tuple[None, None]: def get_infer_target(self) -> tuple[None, None]:
return None, None return None, None
def _get_result_from_keys(self, keys: str | list[str]) -> Any:
if not isinstance(self.selections, dict):
raise TypeError("Selections must be a dictionary to get result by keys.")
if self.return_type == SelectionReturnType.KEY:
result: Any = keys
elif self.return_type == SelectionReturnType.VALUE:
if isinstance(keys, list):
result = [self.selections[key].value for key in keys]
elif isinstance(keys, str):
result = self.selections[keys].value
elif self.return_type == SelectionReturnType.ITEMS:
if isinstance(keys, list):
result = {key: self.selections[key] for key in keys}
elif isinstance(keys, str):
result = {keys: self.selections[keys]}
elif self.return_type == SelectionReturnType.DESCRIPTION:
if isinstance(keys, list):
result = [self.selections[key].description for key in keys]
elif isinstance(keys, str):
result = self.selections[keys].description
elif self.return_type == SelectionReturnType.DESCRIPTION_VALUE:
if isinstance(keys, list):
result = {
self.selections[key].description: self.selections[key].value
for key in keys
}
elif isinstance(keys, str):
result = {self.selections[keys].description: self.selections[keys].value}
else:
raise ValueError(f"Unsupported return type: {self.return_type}")
return result
async def _run(self, *args, **kwargs) -> Any: async def _run(self, *args, **kwargs) -> Any:
kwargs = self._maybe_inject_last_result(kwargs) kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext( context = ExecutionContext(
@ -191,7 +243,7 @@ class SelectionAction(BaseAction):
if self.never_prompt and not effective_default: if self.never_prompt and not effective_default:
raise ValueError( raise ValueError(
f"[{self.name}] 'never_prompt' is True but no valid default_selection " f"[{self.name}] 'never_prompt' is True but no valid default_selection "
"was provided." "or usable last_result was available."
) )
context.start_timer() context.start_timer()
@ -206,7 +258,7 @@ class SelectionAction(BaseAction):
formatter=self.cancel_formatter, formatter=self.cancel_formatter,
) )
if not self.never_prompt: if not self.never_prompt:
index: int | str = await prompt_for_index( indices: int | list[int] = await prompt_for_index(
len(self.selections), len(self.selections),
table, table,
default_selection=effective_default, default_selection=effective_default,
@ -214,12 +266,30 @@ class SelectionAction(BaseAction):
prompt_session=self.prompt_session, prompt_session=self.prompt_session,
prompt_message=self.prompt_message, prompt_message=self.prompt_message,
show_table=self.show_table, show_table=self.show_table,
number_selections=self.number_selections,
separator=self.separator,
allow_duplicates=self.allow_duplicates,
cancel_key=self.cancel_key,
) )
else: else:
index = effective_default if effective_default:
if int(index) == int(self.cancel_key): indices = int(effective_default)
else:
raise ValueError(
f"[{self.name}] 'never_prompt' is True but no valid "
"default_selection was provided."
)
if indices == int(self.cancel_key):
raise CancelSignal("User cancelled the selection.") raise CancelSignal("User cancelled the selection.")
result: Any = self.selections[int(index)] if isinstance(indices, list):
result: str | list[str] = [
self.selections[index] for index in indices
]
elif isinstance(indices, int):
result = self.selections[indices]
else:
assert False, "unreachable"
elif isinstance(self.selections, dict): elif isinstance(self.selections, dict):
cancel_option = { cancel_option = {
self.cancel_key: SelectionOption( self.cancel_key: SelectionOption(
@ -232,7 +302,7 @@ class SelectionAction(BaseAction):
columns=self.columns, columns=self.columns,
) )
if not self.never_prompt: if not self.never_prompt:
key = await prompt_for_selection( keys = await prompt_for_selection(
(self.selections | cancel_option).keys(), (self.selections | cancel_option).keys(),
table, table,
default_selection=effective_default, default_selection=effective_default,
@ -240,25 +310,17 @@ class SelectionAction(BaseAction):
prompt_session=self.prompt_session, prompt_session=self.prompt_session,
prompt_message=self.prompt_message, prompt_message=self.prompt_message,
show_table=self.show_table, show_table=self.show_table,
number_selections=self.number_selections,
separator=self.separator,
allow_duplicates=self.allow_duplicates,
cancel_key=self.cancel_key,
) )
else: else:
key = effective_default keys = effective_default
if key == self.cancel_key: if keys == self.cancel_key:
raise CancelSignal("User cancelled the selection.") raise CancelSignal("User cancelled the selection.")
if self.return_type == SelectionReturnType.KEY:
result = key result = self._get_result_from_keys(keys)
elif self.return_type == SelectionReturnType.VALUE:
result = self.selections[key].value
elif self.return_type == SelectionReturnType.ITEMS:
result = {key: self.selections[key]}
elif self.return_type == SelectionReturnType.DESCRIPTION:
result = self.selections[key].description
elif self.return_type == SelectionReturnType.DESCRIPTION_VALUE:
result = {
self.selections[key].description: self.selections[key].value
}
else:
raise ValueError(f"Unsupported return type: {self.return_type}")
else: else:
raise TypeError( raise TypeError(
"'selections' must be a list[str] or dict[str, Any], " "'selections' must be a list[str] or dict[str, Any], "

View File

@ -29,6 +29,7 @@ class UserInputAction(BaseAction):
name: str, name: str,
*, *,
prompt_text: str = "Input > ", prompt_text: str = "Input > ",
default_text: str = "",
validator: Validator | None = None, validator: Validator | None = None,
console: Console | None = None, console: Console | None = None,
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
@ -45,6 +46,7 @@ class UserInputAction(BaseAction):
elif console: elif console:
raise ValueError("`console` must be an instance of `rich.console.Console`") raise ValueError("`console` must be an instance of `rich.console.Console`")
self.prompt_session = prompt_session or PromptSession() self.prompt_session = prompt_session or PromptSession()
self.default_text = default_text
def get_infer_target(self) -> tuple[None, None]: def get_infer_target(self) -> tuple[None, None]:
return None, None return None, None
@ -67,6 +69,7 @@ class UserInputAction(BaseAction):
answer = await self.prompt_session.prompt_async( answer = await self.prompt_session.prompt_async(
prompt_text, prompt_text,
validator=self.validator, validator=self.validator,
default=kwargs.get("default_text", self.default_text),
) )
context.result = answer context.result = answer
await self.hooks.trigger(HookType.ON_SUCCESS, context) await self.hooks.trigger(HookType.ON_SUCCESS, context)

View File

@ -34,8 +34,8 @@ 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
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
from falyx.parsers.argparse import CommandArgumentParser from falyx.parser.argparse import CommandArgumentParser
from falyx.parsers.signature import infer_args_from_func from falyx.parser.signature import infer_args_from_func
from falyx.prompt_utils import confirm_async, should_prompt_user from falyx.prompt_utils import confirm_async, should_prompt_user
from falyx.protocols import ArgParserProtocol from falyx.protocols import ArgParserProtocol
from falyx.retry import RetryPolicy from falyx.retry import RetryPolicy

View File

@ -129,7 +129,7 @@ class ExecutionContext(BaseModel):
args = ", ".join(map(repr, self.args)) args = ", ".join(map(repr, self.args))
kwargs = ", ".join(f"{key}={value!r}" for key, value in self.kwargs.items()) kwargs = ", ".join(f"{key}={value!r}" for key, value in self.kwargs.items())
signature = ", ".join(filter(None, [args, kwargs])) signature = ", ".join(filter(None, [args, kwargs]))
return f"{self.name} ({signature})" return f"{self.action} ({signature})"
def as_dict(self) -> dict: def as_dict(self) -> dict:
return { return {

View File

@ -112,7 +112,7 @@ class ExecutionRegistry:
cls, cls,
name: str = "", name: str = "",
index: int | None = None, index: int | None = None,
result: int | None = None, result_index: int | None = None,
clear: bool = False, clear: bool = False,
last_result: bool = False, last_result: bool = False,
status: Literal["all", "success", "error"] = "all", status: Literal["all", "success", "error"] = "all",
@ -138,12 +138,12 @@ class ExecutionRegistry:
) )
return return
if result is not None and result >= 0: if result_index is not None and result_index >= 0:
try: try:
result_context = cls._store_by_index[result] result_context = cls._store_by_index[result_index]
except KeyError: except KeyError:
cls._console.print( cls._console.print(
f"[{OneColors.DARK_RED}]❌ No execution found for index {index}." f"[{OneColors.DARK_RED}]❌ No execution found for index {result_index}."
) )
return return
cls._console.print(f"{result_context.signature}:") cls._console.print(f"{result_context.signature}:")

View File

@ -59,7 +59,7 @@ from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import Hook, HookManager, HookType from falyx.hook_manager import Hook, HookManager, HookType
from falyx.logger import logger from falyx.logger import logger
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
from falyx.parsers import CommandArgumentParser, FalyxParsers, get_arg_parsers from falyx.parser import CommandArgumentParser, FalyxParsers, get_arg_parsers
from falyx.protocols import ArgParserProtocol from falyx.protocols import ArgParserProtocol
from falyx.retry import RetryPolicy from falyx.retry import RetryPolicy
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
@ -330,7 +330,13 @@ class Falyx:
action="store_true", action="store_true",
help="Clear the Execution History.", help="Clear the Execution History.",
) )
parser.add_argument("-r", "--result", type=int, help="Get the result by index") parser.add_argument(
"-r",
"--result",
type=int,
dest="result_index",
help="Get the result by index",
)
parser.add_argument( parser.add_argument(
"-l", "--last-result", action="store_true", help="Get the last result" "-l", "--last-result", action="store_true", help="Get the last result"
) )

View File

@ -12,7 +12,7 @@ from rich.text import Text
from falyx.action.base import BaseAction from falyx.action.base import BaseAction
from falyx.exceptions import CommandArgumentError from falyx.exceptions import CommandArgumentError
from falyx.parsers.utils import coerce_value from falyx.parser.utils import coerce_value
from falyx.signals import HelpSignal from falyx.signals import HelpSignal

View File

@ -7,7 +7,7 @@ from dateutil import parser as date_parser
from falyx.action.base import BaseAction from falyx.action.base import BaseAction
from falyx.logger import logger from falyx.logger import logger
from falyx.parsers.signature import infer_args_from_func from falyx.parser.signature import infer_args_from_func
def coerce_bool(value: str) -> bool: def coerce_bool(value: str) -> bool:

View File

@ -11,7 +11,7 @@ from rich.table import Table
from falyx.themes import OneColors from falyx.themes import OneColors
from falyx.utils import CaseInsensitiveDict, chunks from falyx.utils import CaseInsensitiveDict, chunks
from falyx.validators import int_range_validator, key_validator from falyx.validators import MultiIndexValidator, MultiKeyValidator
@dataclass @dataclass
@ -271,7 +271,11 @@ async def prompt_for_index(
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ", prompt_message: str = "Select an option > ",
show_table: bool = True, show_table: bool = True,
) -> int: number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
cancel_key: str = "",
) -> int | list[int]:
prompt_session = prompt_session or PromptSession() prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="truecolor") console = console or Console(color_system="truecolor")
@ -280,10 +284,22 @@ async def prompt_for_index(
selection = await prompt_session.prompt_async( selection = await prompt_session.prompt_async(
message=prompt_message, message=prompt_message,
validator=int_range_validator(min_index, max_index), validator=MultiIndexValidator(
min_index,
max_index,
number_selections,
separator,
allow_duplicates,
cancel_key,
),
default=default_selection, default=default_selection,
) )
return int(selection)
if selection.strip() == cancel_key:
return int(cancel_key)
if isinstance(number_selections, int) and number_selections == 1:
return int(selection.strip())
return [int(index.strip()) for index in selection.strip().split(separator)]
async def prompt_for_selection( async def prompt_for_selection(
@ -295,7 +311,11 @@ async def prompt_for_selection(
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ", prompt_message: str = "Select an option > ",
show_table: bool = True, show_table: bool = True,
) -> str: number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
cancel_key: str = "",
) -> str | list[str]:
"""Prompt the user to select a key from a set of options. Return the selected key.""" """Prompt the user to select a key from a set of options. Return the selected key."""
prompt_session = prompt_session or PromptSession() prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="truecolor") console = console or Console(color_system="truecolor")
@ -305,11 +325,17 @@ async def prompt_for_selection(
selected = await prompt_session.prompt_async( selected = await prompt_session.prompt_async(
message=prompt_message, message=prompt_message,
validator=key_validator(keys), validator=MultiKeyValidator(
keys, number_selections, separator, allow_duplicates, cancel_key
),
default=default_selection, default=default_selection,
) )
return selected if selected.strip() == cancel_key:
return cancel_key
if isinstance(number_selections, int) and number_selections == 1:
return selected.strip()
return [key.strip() for key in selected.strip().split(separator)]
async def select_value_from_list( async def select_value_from_list(
@ -320,6 +346,10 @@ async def select_value_from_list(
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ", prompt_message: str = "Select an option > ",
default_selection: str = "", default_selection: str = "",
number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
cancel_key: str = "",
columns: int = 4, columns: int = 4,
caption: str = "", caption: str = "",
box_style: box.Box = box.SIMPLE, box_style: box.Box = box.SIMPLE,
@ -332,7 +362,7 @@ async def select_value_from_list(
title_style: str = "", title_style: str = "",
caption_style: str = "", caption_style: str = "",
highlight: bool = False, highlight: bool = False,
): ) -> str | list[str]:
"""Prompt for a selection. Return the selected item.""" """Prompt for a selection. Return the selected item."""
table = render_selection_indexed_table( table = render_selection_indexed_table(
title=title, title=title,
@ -360,8 +390,14 @@ async def select_value_from_list(
console=console, console=console,
prompt_session=prompt_session, prompt_session=prompt_session,
prompt_message=prompt_message, prompt_message=prompt_message,
number_selections=number_selections,
separator=separator,
allow_duplicates=allow_duplicates,
cancel_key=cancel_key,
) )
if isinstance(selection_index, list):
return [selections[i] for i in selection_index]
return selections[selection_index] return selections[selection_index]
@ -373,7 +409,11 @@ async def select_key_from_dict(
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ", prompt_message: str = "Select an option > ",
default_selection: str = "", default_selection: str = "",
) -> Any: number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
cancel_key: str = "",
) -> str | list[str]:
"""Prompt for a key from a dict, returns the key.""" """Prompt for a key from a dict, returns the key."""
prompt_session = prompt_session or PromptSession() prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="truecolor") console = console or Console(color_system="truecolor")
@ -387,6 +427,10 @@ async def select_key_from_dict(
console=console, console=console,
prompt_session=prompt_session, prompt_session=prompt_session,
prompt_message=prompt_message, prompt_message=prompt_message,
number_selections=number_selections,
separator=separator,
allow_duplicates=allow_duplicates,
cancel_key=cancel_key,
) )
@ -398,7 +442,11 @@ async def select_value_from_dict(
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ", prompt_message: str = "Select an option > ",
default_selection: str = "", default_selection: str = "",
) -> Any: number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
cancel_key: str = "",
) -> Any | list[Any]:
"""Prompt for a key from a dict, but return the value.""" """Prompt for a key from a dict, but return the value."""
prompt_session = prompt_session or PromptSession() prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="truecolor") console = console or Console(color_system="truecolor")
@ -412,8 +460,14 @@ async def select_value_from_dict(
console=console, console=console,
prompt_session=prompt_session, prompt_session=prompt_session,
prompt_message=prompt_message, prompt_message=prompt_message,
number_selections=number_selections,
separator=separator,
allow_duplicates=allow_duplicates,
cancel_key=cancel_key,
) )
if isinstance(selection_key, list):
return [selections[key].value for key in selection_key]
return selections[selection_key].value return selections[selection_key].value
@ -425,7 +479,11 @@ async def get_selection_from_dict_menu(
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ", prompt_message: str = "Select an option > ",
default_selection: str = "", default_selection: str = "",
): number_selections: int | str = 1,
separator: str = ",",
allow_duplicates: bool = False,
cancel_key: str = "",
) -> Any | list[Any]:
"""Prompt for a key from a dict, but return the value.""" """Prompt for a key from a dict, but return the value."""
table = render_selection_dict_table( table = render_selection_dict_table(
title, title,
@ -439,4 +497,8 @@ async def get_selection_from_dict_menu(
prompt_session=prompt_session, prompt_session=prompt_session,
prompt_message=prompt_message, prompt_message=prompt_message,
default_selection=default_selection, default_selection=default_selection,
number_selections=number_selections,
separator=separator,
allow_duplicates=allow_duplicates,
cancel_key=cancel_key,
) )

View File

@ -2,7 +2,7 @@
"""validators.py""" """validators.py"""
from typing import KeysView, Sequence from typing import KeysView, Sequence
from prompt_toolkit.validation import Validator from prompt_toolkit.validation import ValidationError, Validator
def int_range_validator(minimum: int, maximum: int) -> Validator: def int_range_validator(minimum: int, maximum: int) -> Validator:
@ -45,3 +45,91 @@ def yes_no_validator() -> Validator:
return True return True
return Validator.from_callable(validate, error_message="Enter 'Y' or 'n'.") return Validator.from_callable(validate, error_message="Enter 'Y' or 'n'.")
class MultiIndexValidator(Validator):
def __init__(
self,
minimum: int,
maximum: int,
number_selections: int | str,
separator: str,
allow_duplicates: bool,
cancel_key: str,
) -> None:
self.minimum = minimum
self.maximum = maximum
self.number_selections = number_selections
self.separator = separator
self.allow_duplicates = allow_duplicates
self.cancel_key = cancel_key
super().__init__()
def validate(self, document):
selections = [
index.strip() for index in document.text.strip().split(self.separator)
]
if not selections or selections == [""]:
raise ValidationError(message="Select at least 1 item.")
if self.cancel_key in selections and len(selections) == 1:
return
elif self.cancel_key in selections:
raise ValidationError(message="Cancel key must be selected alone.")
for selection in selections:
try:
index = int(selection)
if not self.minimum <= index <= self.maximum:
raise ValidationError(
message=f"Invalid selection: {selection}. Select a number between {self.minimum} and {self.maximum}."
)
except ValueError:
raise ValidationError(
message=f"Invalid selection: {selection}. Select a number between {self.minimum} and {self.maximum}."
)
if not self.allow_duplicates and selections.count(selection) > 1:
raise ValidationError(message=f"Duplicate selection: {selection}")
if isinstance(self.number_selections, int):
if self.number_selections == 1 and len(selections) > 1:
raise ValidationError(message="Invalid selection. Select only 1 item.")
if len(selections) != self.number_selections:
raise ValidationError(
message=f"Select exactly {self.number_selections} items separated by '{self.separator}'"
)
class MultiKeyValidator(Validator):
def __init__(
self,
keys: Sequence[str] | KeysView[str],
number_selections: int | str,
separator: str,
allow_duplicates: bool,
cancel_key: str,
) -> None:
self.keys = keys
self.separator = separator
self.number_selections = number_selections
self.allow_duplicates = allow_duplicates
self.cancel_key = cancel_key
super().__init__()
def validate(self, document):
selections = [key.strip() for key in document.text.strip().split(self.separator)]
if not selections or selections == [""]:
raise ValidationError(message="Select at least 1 item.")
if self.cancel_key in selections and len(selections) == 1:
return
elif self.cancel_key in selections:
raise ValidationError(message="Cancel key must be selected alone.")
for selection in selections:
if selection.upper() not in [key.upper() for key in self.keys]:
raise ValidationError(message=f"Invalid selection: {selection}")
if not self.allow_duplicates and selections.count(selection) > 1:
raise ValidationError(message=f"Duplicate selection: {selection}")
if isinstance(self.number_selections, int):
if self.number_selections == 1 and len(selections) > 1:
raise ValidationError(message="Invalid selection. Select only 1 item.")
if len(selections) != self.number_selections:
raise ValidationError(
message=f"Select exactly {self.number_selections} items separated by '{self.separator}'"
)

View File

@ -1 +1 @@
__version__ = "0.1.50" __version__ = "0.1.51"

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "falyx" name = "falyx"
version = "0.1.50" version = "0.1.51"
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"

View File

@ -38,13 +38,14 @@ async def test_action_async_callable():
action = Action("test_action", async_callable) action = Action("test_action", async_callable)
result = await action() result = await action()
assert result == "Hello, World!" assert result == "Hello, World!"
print(action)
assert ( assert (
str(action) str(action)
== "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)" == "Action(name='test_action', action=async_callable, retry=False, rollback=False)"
) )
assert ( assert (
repr(action) repr(action)
== "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)" == "Action(name='test_action', action=async_callable, retry=False, rollback=False)"
) )

View File

@ -50,9 +50,10 @@ def test_command_str():
"""Test if Command string representation is correct.""" """Test if Command string representation is correct."""
action = Action("test_action", dummy_action) action = Action("test_action", dummy_action)
cmd = Command(key="TEST", description="Test Command", action=action) cmd = Command(key="TEST", description="Test Command", action=action)
print(cmd)
assert ( assert (
str(cmd) str(cmd)
== "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, args=(), kwargs={}, retry=False)')" == "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, retry=False, rollback=False)')"
) )

View File

@ -1,7 +1,7 @@
import pytest import pytest
from falyx.exceptions import CommandArgumentError from falyx.exceptions import CommandArgumentError
from falyx.parsers import ArgumentAction, CommandArgumentParser from falyx.parser import ArgumentAction, CommandArgumentParser
from falyx.signals import HelpSignal from falyx.signals import HelpSignal

View File

@ -2,7 +2,7 @@ import pytest
from falyx.action import Action, SelectionAction from falyx.action import Action, SelectionAction
from falyx.exceptions import CommandArgumentError from falyx.exceptions import CommandArgumentError
from falyx.parsers import ArgumentAction, CommandArgumentParser from falyx.parser import ArgumentAction, CommandArgumentParser
def test_add_argument(): def test_add_argument():

View File

@ -1,6 +1,6 @@
import pytest import pytest
from falyx.parsers import Argument, ArgumentAction from falyx.parser import Argument, ArgumentAction
def test_positional_text_with_choices(): def test_positional_text_with_choices():

View File

@ -1,4 +1,4 @@
from falyx.parsers import ArgumentAction from falyx.parser import ArgumentAction
def test_argument_action(): def test_argument_action():

View File

@ -1,7 +1,7 @@
import pytest import pytest
from falyx.exceptions import CommandArgumentError from falyx.exceptions import CommandArgumentError
from falyx.parsers import CommandArgumentParser from falyx.parser import CommandArgumentParser
def test_str(): def test_str():

View File

@ -5,7 +5,7 @@ from typing import Literal
import pytest import pytest
from falyx.parsers.utils import coerce_value from falyx.parser.utils import coerce_value
# --- Tests --- # --- Tests ---

View File

@ -1,7 +1,7 @@
import pytest import pytest
from falyx.exceptions import CommandArgumentError from falyx.exceptions import CommandArgumentError
from falyx.parsers import ArgumentAction, CommandArgumentParser from falyx.parser import ArgumentAction, CommandArgumentParser
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@ -1,7 +1,7 @@
import pytest import pytest
from falyx.exceptions import CommandArgumentError from falyx.exceptions import CommandArgumentError
from falyx.parsers import ArgumentAction, CommandArgumentParser from falyx.parser import ArgumentAction, CommandArgumentParser
@pytest.mark.asyncio @pytest.mark.asyncio