From 53ba6a896ab96d4f64adde9e8cc17fa983db1dbf Mon Sep 17 00:00:00 2001 From: Roland Thomas Date: Sun, 8 Jun 2025 12:09:16 -0400 Subject: [PATCH] Add multi selecto to SelectionAction and SelectFileAction, Allow IOActions to receive no input, Rename subpackage falyx.parsers -> falyx.parser, Add default_text to UserInputAction --- examples/file_select.py | 5 +- examples/selection_demo.py | 48 ++++++++- examples/type_validation.py | 2 +- falyx/__main__.py | 2 +- falyx/action/action.py | 4 +- falyx/action/action_group.py | 2 +- falyx/action/io_action.py | 5 +- falyx/action/menu_action.py | 6 +- falyx/action/process_pool_action.py | 2 +- falyx/action/select_file_action.py | 36 ++++++- falyx/action/selection_action.py | 108 ++++++++++++++++----- falyx/action/user_input_action.py | 3 + falyx/command.py | 4 +- falyx/context.py | 2 +- falyx/execution_registry.py | 8 +- falyx/falyx.py | 10 +- falyx/{parsers => parser}/.pytyped | 0 falyx/{parsers => parser}/__init__.py | 0 falyx/{parsers => parser}/argparse.py | 2 +- falyx/{parsers => parser}/parsers.py | 0 falyx/{parsers => parser}/signature.py | 0 falyx/{parsers => parser}/utils.py | 2 +- falyx/selection.py | 84 +++++++++++++--- falyx/validators.py | 90 ++++++++++++++++- falyx/version.py | 2 +- pyproject.toml | 2 +- tests/test_action_basic.py | 5 +- tests/test_command.py | 3 +- tests/test_command_argument_parser.py | 2 +- tests/test_parsers/test_action.py | 2 +- tests/test_parsers/test_argument.py | 2 +- tests/test_parsers/test_argument_action.py | 2 +- tests/test_parsers/test_basics.py | 2 +- tests/test_parsers/test_coerce_value.py | 2 +- tests/test_parsers/test_nargs.py | 2 +- tests/test_parsers/test_posix_bundling.py | 2 +- 36 files changed, 373 insertions(+), 80 deletions(-) rename falyx/{parsers => parser}/.pytyped (100%) rename falyx/{parsers => parser}/__init__.py (100%) rename falyx/{parsers => parser}/argparse.py (99%) rename falyx/{parsers => parser}/parsers.py (100%) rename falyx/{parsers => parser}/signature.py (100%) rename falyx/{parsers => parser}/utils.py (98%) diff --git a/examples/file_select.py b/examples/file_select.py index 00fc42c..b55c564 100644 --- a/examples/file_select.py +++ b/examples/file_select.py @@ -6,11 +6,12 @@ from falyx.action.types import FileReturnType sf = SelectFileAction( name="select_file", - suffix_filter=".py", + suffix_filter=".yaml", title="Select a YAML file", - prompt_message="Choose > ", + prompt_message="Choose 2 > ", return_type=FileReturnType.TEXT, columns=3, + number_selections=2, ) flx = Falyx() diff --git a/examples/selection_demo.py b/examples/selection_demo.py index 87a5cbb..3139d6b 100644 --- a/examples/selection_demo.py +++ b/examples/selection_demo.py @@ -1,5 +1,7 @@ import asyncio +from uuid import uuid4 +from falyx import Falyx from falyx.action import SelectionAction from falyx.selection import SelectionOption from falyx.signals import CancelSignal @@ -24,7 +26,45 @@ select = SelectionAction( show_table=True, ) -try: - print(asyncio.run(select())) -except CancelSignal: - print("Selection was cancelled.") +list_selections = [uuid4() for _ in range(10)] + +list_select = SelectionAction( + 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()) diff --git a/examples/type_validation.py b/examples/type_validation.py index 5d9418d..cedc176 100644 --- a/examples/type_validation.py +++ b/examples/type_validation.py @@ -2,7 +2,7 @@ import asyncio from uuid import UUID, uuid4 from falyx import Falyx -from falyx.parsers import CommandArgumentParser +from falyx.parser import CommandArgumentParser flx = Falyx("Test Type Validation") diff --git a/falyx/__main__.py b/falyx/__main__.py index 0583c3c..d3737a9 100644 --- a/falyx/__main__.py +++ b/falyx/__main__.py @@ -14,7 +14,7 @@ from typing import Any from falyx.config import loader 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: diff --git a/falyx/action/action.py b/falyx/action/action.py index e8a0996..a6d3009 100644 --- a/falyx/action/action.py +++ b/falyx/action/action.py @@ -157,6 +157,6 @@ class Action(BaseAction): return ( f"Action(name={self.name!r}, 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})" ) diff --git a/falyx/action/action_group.py b/falyx/action/action_group.py index 3fc6c45..2608705 100644 --- a/falyx/action/action_group.py +++ b/falyx/action/action_group.py @@ -11,7 +11,7 @@ from falyx.context import ExecutionContext, SharedContext from falyx.execution_registry import ExecutionRegistry as er from falyx.hook_manager import Hook, HookManager, HookType 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 diff --git a/falyx/action/io_action.py b/falyx/action/io_action.py index b26ef01..136dbcd 100644 --- a/falyx/action/io_action.py +++ b/falyx/action/io_action.py @@ -93,10 +93,7 @@ class BaseIOAction(BaseAction): if self.inject_last_result and self.shared_context: return self.shared_context.last_result() - logger.debug( - "[%s] No input provided and no last result found for injection.", self.name - ) - raise FalyxError("No input provided and no last result to inject.") + return "" def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]: return None, None diff --git a/falyx/action/menu_action.py b/falyx/action/menu_action.py index 2975f64..455e579 100644 --- a/falyx/action/menu_action.py +++ b/falyx/action/menu_action.py @@ -111,7 +111,7 @@ class MenuAction(BaseAction): key = effective_default if not self.never_prompt: table = self._build_table() - key = await prompt_for_selection( + key_ = await prompt_for_selection( self.menu_options.keys(), table, default_selection=self.default_selection, @@ -120,6 +120,10 @@ class MenuAction(BaseAction): prompt_message=self.prompt_message, 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] result = await option.action(*args, **kwargs) context.result = result diff --git a/falyx/action/process_pool_action.py b/falyx/action/process_pool_action.py index 8499c6f..d9665dd 100644 --- a/falyx/action/process_pool_action.py +++ b/falyx/action/process_pool_action.py @@ -14,7 +14,7 @@ from falyx.context import ExecutionContext, SharedContext from falyx.execution_registry import ExecutionRegistry as er from falyx.hook_manager import HookManager, HookType 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 diff --git a/falyx/action/select_file_action.py b/falyx/action/select_file_action.py index 3943a6d..02d9ef2 100644 --- a/falyx/action/select_file_action.py +++ b/falyx/action/select_file_action.py @@ -66,6 +66,9 @@ class SelectFileAction(BaseAction): style: str = OneColors.WHITE, suffix_filter: str | None = None, return_type: FileReturnType | str = FileReturnType.PATH, + number_selections: int | str = 1, + separator: str = ",", + allow_duplicates: bool = False, console: Console | None = None, prompt_session: PromptSession | None = None, ): @@ -76,6 +79,9 @@ class SelectFileAction(BaseAction): self.prompt_message = prompt_message self.suffix_filter = suffix_filter self.style = style + self.number_selections = number_selections + self.separator = separator + self.allow_duplicates = allow_duplicates if isinstance(console, Console): self.console = console elif console: @@ -83,6 +89,21 @@ class SelectFileAction(BaseAction): self.prompt_session = prompt_session or PromptSession() 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: if isinstance(return_type, FileReturnType): return return_type @@ -163,18 +184,25 @@ class SelectFileAction(BaseAction): title=self.title, selections=options | cancel_option, columns=self.columns ) - key = await prompt_for_selection( + keys = await prompt_for_selection( (options | cancel_option).keys(), table, console=self.console, prompt_session=self.prompt_session, 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: - raise CancelSignal("User canceled the selection.") + if isinstance(keys, str): + 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 await self.hooks.trigger(HookType.ON_SUCCESS, context) return result diff --git a/falyx/action/selection_action.py b/falyx/action/selection_action.py index add3f87..d4f460f 100644 --- a/falyx/action/selection_action.py +++ b/falyx/action/selection_action.py @@ -48,6 +48,9 @@ class SelectionAction(BaseAction): columns: int = 5, prompt_message: str = "Select > ", default_selection: str = "", + number_selections: int | str = 1, + separator: str = ",", + allow_duplicates: bool = False, inject_last_result: bool = False, inject_into: str = "last_result", return_type: SelectionReturnType | str = "value", @@ -73,9 +76,26 @@ class SelectionAction(BaseAction): raise ValueError("`console` must be an instance of `rich.console.Console`") self.prompt_session = prompt_session or PromptSession() self.default_selection = default_selection + self.number_selections = number_selections + self.separator = separator + self.allow_duplicates = allow_duplicates self.prompt_message = prompt_message 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( self, return_type: SelectionReturnType | str @@ -156,6 +176,38 @@ class SelectionAction(BaseAction): def get_infer_target(self) -> tuple[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: kwargs = self._maybe_inject_last_result(kwargs) context = ExecutionContext( @@ -191,7 +243,7 @@ class SelectionAction(BaseAction): if self.never_prompt and not effective_default: raise ValueError( f"[{self.name}] 'never_prompt' is True but no valid default_selection " - "was provided." + "or usable last_result was available." ) context.start_timer() @@ -206,7 +258,7 @@ class SelectionAction(BaseAction): formatter=self.cancel_formatter, ) if not self.never_prompt: - index: int | str = await prompt_for_index( + indices: int | list[int] = await prompt_for_index( len(self.selections), table, default_selection=effective_default, @@ -214,12 +266,30 @@ class SelectionAction(BaseAction): prompt_session=self.prompt_session, prompt_message=self.prompt_message, show_table=self.show_table, + number_selections=self.number_selections, + separator=self.separator, + allow_duplicates=self.allow_duplicates, + cancel_key=self.cancel_key, ) else: - index = effective_default - if int(index) == int(self.cancel_key): + if effective_default: + 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.") - 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): cancel_option = { self.cancel_key: SelectionOption( @@ -232,7 +302,7 @@ class SelectionAction(BaseAction): columns=self.columns, ) if not self.never_prompt: - key = await prompt_for_selection( + keys = await prompt_for_selection( (self.selections | cancel_option).keys(), table, default_selection=effective_default, @@ -240,25 +310,17 @@ class SelectionAction(BaseAction): prompt_session=self.prompt_session, prompt_message=self.prompt_message, show_table=self.show_table, + number_selections=self.number_selections, + separator=self.separator, + allow_duplicates=self.allow_duplicates, + cancel_key=self.cancel_key, ) else: - key = effective_default - if key == self.cancel_key: + keys = effective_default + if keys == self.cancel_key: raise CancelSignal("User cancelled the selection.") - if self.return_type == SelectionReturnType.KEY: - result = key - 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}") + + result = self._get_result_from_keys(keys) else: raise TypeError( "'selections' must be a list[str] or dict[str, Any], " diff --git a/falyx/action/user_input_action.py b/falyx/action/user_input_action.py index 54b5e5f..80b3fcc 100644 --- a/falyx/action/user_input_action.py +++ b/falyx/action/user_input_action.py @@ -29,6 +29,7 @@ class UserInputAction(BaseAction): name: str, *, prompt_text: str = "Input > ", + default_text: str = "", validator: Validator | None = None, console: Console | None = None, prompt_session: PromptSession | None = None, @@ -45,6 +46,7 @@ class UserInputAction(BaseAction): elif console: raise ValueError("`console` must be an instance of `rich.console.Console`") self.prompt_session = prompt_session or PromptSession() + self.default_text = default_text def get_infer_target(self) -> tuple[None, None]: return None, None @@ -67,6 +69,7 @@ class UserInputAction(BaseAction): answer = await self.prompt_session.prompt_async( prompt_text, validator=self.validator, + default=kwargs.get("default_text", self.default_text), ) context.result = answer await self.hooks.trigger(HookType.ON_SUCCESS, context) diff --git a/falyx/command.py b/falyx/command.py index be66f52..9a8172c 100644 --- a/falyx/command.py +++ b/falyx/command.py @@ -34,8 +34,8 @@ from falyx.execution_registry import ExecutionRegistry as er from falyx.hook_manager import HookManager, HookType from falyx.logger import logger from falyx.options_manager import OptionsManager -from falyx.parsers.argparse import CommandArgumentParser -from falyx.parsers.signature import infer_args_from_func +from falyx.parser.argparse import CommandArgumentParser +from falyx.parser.signature import infer_args_from_func from falyx.prompt_utils import confirm_async, should_prompt_user from falyx.protocols import ArgParserProtocol from falyx.retry import RetryPolicy diff --git a/falyx/context.py b/falyx/context.py index 5f39731..cdd424a 100644 --- a/falyx/context.py +++ b/falyx/context.py @@ -129,7 +129,7 @@ class ExecutionContext(BaseModel): args = ", ".join(map(repr, self.args)) kwargs = ", ".join(f"{key}={value!r}" for key, value in self.kwargs.items()) signature = ", ".join(filter(None, [args, kwargs])) - return f"{self.name} ({signature})" + return f"{self.action} ({signature})" def as_dict(self) -> dict: return { diff --git a/falyx/execution_registry.py b/falyx/execution_registry.py index 4a87ed2..ddbce66 100644 --- a/falyx/execution_registry.py +++ b/falyx/execution_registry.py @@ -112,7 +112,7 @@ class ExecutionRegistry: cls, name: str = "", index: int | None = None, - result: int | None = None, + result_index: int | None = None, clear: bool = False, last_result: bool = False, status: Literal["all", "success", "error"] = "all", @@ -138,12 +138,12 @@ class ExecutionRegistry: ) return - if result is not None and result >= 0: + if result_index is not None and result_index >= 0: try: - result_context = cls._store_by_index[result] + result_context = cls._store_by_index[result_index] except KeyError: 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 cls._console.print(f"{result_context.signature}:") diff --git a/falyx/falyx.py b/falyx/falyx.py index c7dbe79..8e2fd59 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -59,7 +59,7 @@ from falyx.execution_registry import ExecutionRegistry as er from falyx.hook_manager import Hook, HookManager, HookType from falyx.logger import logger 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.retry import RetryPolicy from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal @@ -330,7 +330,13 @@ class Falyx: action="store_true", 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( "-l", "--last-result", action="store_true", help="Get the last result" ) diff --git a/falyx/parsers/.pytyped b/falyx/parser/.pytyped similarity index 100% rename from falyx/parsers/.pytyped rename to falyx/parser/.pytyped diff --git a/falyx/parsers/__init__.py b/falyx/parser/__init__.py similarity index 100% rename from falyx/parsers/__init__.py rename to falyx/parser/__init__.py diff --git a/falyx/parsers/argparse.py b/falyx/parser/argparse.py similarity index 99% rename from falyx/parsers/argparse.py rename to falyx/parser/argparse.py index e4d6651..a4ff84b 100644 --- a/falyx/parsers/argparse.py +++ b/falyx/parser/argparse.py @@ -12,7 +12,7 @@ from rich.text import Text from falyx.action.base import BaseAction from falyx.exceptions import CommandArgumentError -from falyx.parsers.utils import coerce_value +from falyx.parser.utils import coerce_value from falyx.signals import HelpSignal diff --git a/falyx/parsers/parsers.py b/falyx/parser/parsers.py similarity index 100% rename from falyx/parsers/parsers.py rename to falyx/parser/parsers.py diff --git a/falyx/parsers/signature.py b/falyx/parser/signature.py similarity index 100% rename from falyx/parsers/signature.py rename to falyx/parser/signature.py diff --git a/falyx/parsers/utils.py b/falyx/parser/utils.py similarity index 98% rename from falyx/parsers/utils.py rename to falyx/parser/utils.py index 4a069d5..821808d 100644 --- a/falyx/parsers/utils.py +++ b/falyx/parser/utils.py @@ -7,7 +7,7 @@ from dateutil import parser as date_parser from falyx.action.base import BaseAction 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: diff --git a/falyx/selection.py b/falyx/selection.py index 8ba63ef..1eea3af 100644 --- a/falyx/selection.py +++ b/falyx/selection.py @@ -11,7 +11,7 @@ from rich.table import Table from falyx.themes import OneColors from falyx.utils import CaseInsensitiveDict, chunks -from falyx.validators import int_range_validator, key_validator +from falyx.validators import MultiIndexValidator, MultiKeyValidator @dataclass @@ -271,7 +271,11 @@ async def prompt_for_index( prompt_session: PromptSession | None = None, prompt_message: str = "Select an option > ", 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() console = console or Console(color_system="truecolor") @@ -280,10 +284,22 @@ async def prompt_for_index( selection = await prompt_session.prompt_async( 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, ) - 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( @@ -295,7 +311,11 @@ async def prompt_for_selection( prompt_session: PromptSession | None = None, prompt_message: str = "Select an option > ", 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_session = prompt_session or PromptSession() console = console or Console(color_system="truecolor") @@ -305,11 +325,17 @@ async def prompt_for_selection( selected = await prompt_session.prompt_async( message=prompt_message, - validator=key_validator(keys), + validator=MultiKeyValidator( + keys, number_selections, separator, allow_duplicates, cancel_key + ), 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( @@ -320,6 +346,10 @@ async def select_value_from_list( prompt_session: PromptSession | None = None, prompt_message: str = "Select an option > ", default_selection: str = "", + number_selections: int | str = 1, + separator: str = ",", + allow_duplicates: bool = False, + cancel_key: str = "", columns: int = 4, caption: str = "", box_style: box.Box = box.SIMPLE, @@ -332,7 +362,7 @@ async def select_value_from_list( title_style: str = "", caption_style: str = "", highlight: bool = False, -): +) -> str | list[str]: """Prompt for a selection. Return the selected item.""" table = render_selection_indexed_table( title=title, @@ -360,8 +390,14 @@ async def select_value_from_list( console=console, prompt_session=prompt_session, 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] @@ -373,7 +409,11 @@ async def select_key_from_dict( prompt_session: PromptSession | None = None, prompt_message: str = "Select an option > ", 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_session = prompt_session or PromptSession() console = console or Console(color_system="truecolor") @@ -387,6 +427,10 @@ async def select_key_from_dict( console=console, prompt_session=prompt_session, 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_message: str = "Select an option > ", 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_session = prompt_session or PromptSession() console = console or Console(color_system="truecolor") @@ -412,8 +460,14 @@ async def select_value_from_dict( console=console, prompt_session=prompt_session, 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 @@ -425,7 +479,11 @@ async def get_selection_from_dict_menu( prompt_session: PromptSession | None = None, prompt_message: str = "Select an option > ", 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.""" table = render_selection_dict_table( title, @@ -439,4 +497,8 @@ async def get_selection_from_dict_menu( prompt_session=prompt_session, prompt_message=prompt_message, default_selection=default_selection, + number_selections=number_selections, + separator=separator, + allow_duplicates=allow_duplicates, + cancel_key=cancel_key, ) diff --git a/falyx/validators.py b/falyx/validators.py index 88141a5..672192a 100644 --- a/falyx/validators.py +++ b/falyx/validators.py @@ -2,7 +2,7 @@ """validators.py""" 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: @@ -45,3 +45,91 @@ def yes_no_validator() -> Validator: return True 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}'" + ) diff --git a/falyx/version.py b/falyx/version.py index a0036bc..86cf628 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.50" +__version__ = "0.1.51" diff --git a/pyproject.toml b/pyproject.toml index 744b499..12a7904 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "falyx" -version = "0.1.50" +version = "0.1.51" description = "Reliable and introspectable async CLI action framework." authors = ["Roland Thomas Jr "] license = "MIT" diff --git a/tests/test_action_basic.py b/tests/test_action_basic.py index 9745b7a..c02f021 100644 --- a/tests/test_action_basic.py +++ b/tests/test_action_basic.py @@ -38,13 +38,14 @@ async def test_action_async_callable(): action = Action("test_action", async_callable) result = await action() assert result == "Hello, World!" + print(action) assert ( 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 ( repr(action) - == "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)" + == "Action(name='test_action', action=async_callable, retry=False, rollback=False)" ) diff --git a/tests/test_command.py b/tests/test_command.py index b0e53c9..b4421a3 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -50,9 +50,10 @@ def test_command_str(): """Test if Command string representation is correct.""" action = Action("test_action", dummy_action) cmd = Command(key="TEST", description="Test Command", action=action) + print(cmd) assert ( 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)')" ) diff --git a/tests/test_command_argument_parser.py b/tests/test_command_argument_parser.py index 40c5eb6..71b90b8 100644 --- a/tests/test_command_argument_parser.py +++ b/tests/test_command_argument_parser.py @@ -1,7 +1,7 @@ import pytest from falyx.exceptions import CommandArgumentError -from falyx.parsers import ArgumentAction, CommandArgumentParser +from falyx.parser import ArgumentAction, CommandArgumentParser from falyx.signals import HelpSignal diff --git a/tests/test_parsers/test_action.py b/tests/test_parsers/test_action.py index f836ffa..b42a578 100644 --- a/tests/test_parsers/test_action.py +++ b/tests/test_parsers/test_action.py @@ -2,7 +2,7 @@ import pytest from falyx.action import Action, SelectionAction from falyx.exceptions import CommandArgumentError -from falyx.parsers import ArgumentAction, CommandArgumentParser +from falyx.parser import ArgumentAction, CommandArgumentParser def test_add_argument(): diff --git a/tests/test_parsers/test_argument.py b/tests/test_parsers/test_argument.py index 8b76b2e..f464dee 100644 --- a/tests/test_parsers/test_argument.py +++ b/tests/test_parsers/test_argument.py @@ -1,6 +1,6 @@ import pytest -from falyx.parsers import Argument, ArgumentAction +from falyx.parser import Argument, ArgumentAction def test_positional_text_with_choices(): diff --git a/tests/test_parsers/test_argument_action.py b/tests/test_parsers/test_argument_action.py index 86fc18e..6221fc1 100644 --- a/tests/test_parsers/test_argument_action.py +++ b/tests/test_parsers/test_argument_action.py @@ -1,4 +1,4 @@ -from falyx.parsers import ArgumentAction +from falyx.parser import ArgumentAction def test_argument_action(): diff --git a/tests/test_parsers/test_basics.py b/tests/test_parsers/test_basics.py index e9497b7..c0c5b40 100644 --- a/tests/test_parsers/test_basics.py +++ b/tests/test_parsers/test_basics.py @@ -1,7 +1,7 @@ import pytest from falyx.exceptions import CommandArgumentError -from falyx.parsers import CommandArgumentParser +from falyx.parser import CommandArgumentParser def test_str(): diff --git a/tests/test_parsers/test_coerce_value.py b/tests/test_parsers/test_coerce_value.py index 1e69d7d..0a5e106 100644 --- a/tests/test_parsers/test_coerce_value.py +++ b/tests/test_parsers/test_coerce_value.py @@ -5,7 +5,7 @@ from typing import Literal import pytest -from falyx.parsers.utils import coerce_value +from falyx.parser.utils import coerce_value # --- Tests --- diff --git a/tests/test_parsers/test_nargs.py b/tests/test_parsers/test_nargs.py index 608c5d7..fbc5ef2 100644 --- a/tests/test_parsers/test_nargs.py +++ b/tests/test_parsers/test_nargs.py @@ -1,7 +1,7 @@ import pytest from falyx.exceptions import CommandArgumentError -from falyx.parsers import ArgumentAction, CommandArgumentParser +from falyx.parser import ArgumentAction, CommandArgumentParser @pytest.mark.asyncio diff --git a/tests/test_parsers/test_posix_bundling.py b/tests/test_parsers/test_posix_bundling.py index ff97197..7c8ba35 100644 --- a/tests/test_parsers/test_posix_bundling.py +++ b/tests/test_parsers/test_posix_bundling.py @@ -1,7 +1,7 @@ import pytest from falyx.exceptions import CommandArgumentError -from falyx.parsers import ArgumentAction, CommandArgumentParser +from falyx.parser import ArgumentAction, CommandArgumentParser @pytest.mark.asyncio