From 4c1498121fdde9bb7f0e3940b012493fbf5a13bf Mon Sep 17 00:00:00 2001 From: Roland Thomas Date: Sat, 12 Jul 2025 11:52:02 -0400 Subject: [PATCH] Add falyx.console for single rich.console.Console instance, Add ConfirmAction, SaveFileAction, Add lazy evaluation for ArgumentAction.ACTION --- examples/confirm_example.py | 108 ++++++++ falyx/__init__.py | 1 + falyx/action/__init__.py | 4 + falyx/action/action_group.py | 6 + falyx/action/base_action.py | 3 +- falyx/action/chained_action.py | 8 +- falyx/action/confirm_action.py | 217 ++++++++++++++++ falyx/action/load_file_action.py | 16 +- falyx/action/menu_action.py | 7 - falyx/action/prompt_menu_action.py | 6 - falyx/action/save_file_action.py | 232 ++++++++++++++++++ falyx/action/select_file_action.py | 8 - falyx/action/selection_action.py | 8 - falyx/action/user_input_action.py | 7 - falyx/bottom_bar.py | 4 +- falyx/command.py | 4 +- falyx/config.py | 4 +- falyx/console.py | 5 + falyx/context.py | 4 +- falyx/execution_registry.py | 1 + falyx/falyx.py | 7 +- falyx/init.py | 4 +- falyx/parser/argument.py | 1 + falyx/parser/command_argument_parser.py | 60 +++-- falyx/parser/utils.py | 5 +- falyx/selection.py | 17 +- falyx/validators.py | 24 ++ falyx/version.py | 2 +- pyproject.toml | 2 +- .../test_parsers/test_multiple_positional.py | 15 ++ 30 files changed, 689 insertions(+), 101 deletions(-) create mode 100644 examples/confirm_example.py create mode 100644 falyx/action/confirm_action.py create mode 100644 falyx/action/save_file_action.py create mode 100644 falyx/console.py diff --git a/examples/confirm_example.py b/examples/confirm_example.py new file mode 100644 index 0000000..b53da30 --- /dev/null +++ b/examples/confirm_example.py @@ -0,0 +1,108 @@ +import asyncio +from typing import Any + +from pydantic import BaseModel + +from falyx import Falyx +from falyx.action import Action, ActionFactory, ChainedAction, ConfirmAction +from falyx.parser import CommandArgumentParser + + +class Dog(BaseModel): + name: str + age: int + breed: str + + +async def get_dogs(*dog_names: str) -> list[Dog]: + """Simulate fetching dog data.""" + await asyncio.sleep(0.1) # Simulate network delay + dogs = [ + Dog(name="Buddy", age=3, breed="Golden Retriever"), + Dog(name="Max", age=5, breed="Beagle"), + Dog(name="Bella", age=2, breed="Bulldog"), + Dog(name="Charlie", age=4, breed="Poodle"), + Dog(name="Lucy", age=1, breed="Labrador"), + Dog(name="Spot", age=6, breed="German Shepherd"), + ] + dogs = [ + dog for dog in dogs if dog.name.upper() in (name.upper() for name in dog_names) + ] + if not dogs: + raise ValueError(f"No dogs found with the names: {', '.join(dog_names)}") + return dogs + + +async def build_json_updates(dogs: list[Dog]) -> list[dict[str, Any]]: + """Build JSON updates for the dogs.""" + print(f"Building JSON updates for {','.join(dog.name for dog in dogs)}") + return [dog.model_dump(mode="json") for dog in dogs] + + +def after_action(dogs) -> None: + if not dogs: + print("No dogs processed.") + return + for result in dogs: + print(Dog(**result)) + + +async def build_chain(dogs: list[Dog]) -> ChainedAction: + return ChainedAction( + name="test_chain", + actions=[ + Action( + name="build_json_updates", + action=build_json_updates, + kwargs={"dogs": dogs}, + ), + ConfirmAction( + name="test_confirm", + message="Do you want to process the dogs?", + confirm_type="yes_no", + return_last_result=True, + inject_into="dogs", + ), + Action( + name="after_action", + action=after_action, + inject_into="dogs", + ), + ], + auto_inject=True, + ) + + +factory = ActionFactory( + name="Dog Post Factory", + factory=build_chain, +) + + +def dog_config(parser: CommandArgumentParser) -> None: + parser.add_argument( + "dogs", + nargs="+", + action="action", + resolver=Action("Get Dogs", get_dogs), + lazy_resolver=False, + help="List of dogs to process.", + ) + + +async def main(): + flx = Falyx("Dog Post Example") + + flx.add_command( + key="D", + description="Post Dog Data", + action=factory, + aliases=["post_dogs"], + argument_config=dog_config, + ) + + await flx.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/falyx/__init__.py b/falyx/__init__.py index d951190..20019b5 100644 --- a/falyx/__init__.py +++ b/falyx/__init__.py @@ -12,6 +12,7 @@ from .falyx import Falyx logger = logging.getLogger("falyx") + __all__ = [ "Falyx", "ExecutionRegistry", diff --git a/falyx/action/__init__.py b/falyx/action/__init__.py index fba0d82..bcce9f4 100644 --- a/falyx/action/__init__.py +++ b/falyx/action/__init__.py @@ -10,6 +10,7 @@ from .action_factory import ActionFactory from .action_group import ActionGroup from .base_action import BaseAction from .chained_action import ChainedAction +from .confirm_action import ConfirmAction from .fallback_action import FallbackAction from .http_action import HTTPAction from .io_action import BaseIOAction @@ -19,6 +20,7 @@ from .menu_action import MenuAction from .process_action import ProcessAction from .process_pool_action import ProcessPoolAction from .prompt_menu_action import PromptMenuAction +from .save_file_action import SaveFileAction from .select_file_action import SelectFileAction from .selection_action import SelectionAction from .shell_action import ShellAction @@ -45,4 +47,6 @@ __all__ = [ "PromptMenuAction", "ProcessPoolAction", "LoadFileAction", + "SaveFileAction", + "ConfirmAction", ] diff --git a/falyx/action/action_group.py b/falyx/action/action_group.py index 8624df6..fc9c9cc 100644 --- a/falyx/action/action_group.py +++ b/falyx/action/action_group.py @@ -14,6 +14,7 @@ from falyx.exceptions import EmptyGroupError 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.parser.utils import same_argument_definitions from falyx.themes.colors import OneColors @@ -96,6 +97,11 @@ class ActionGroup(BaseAction, ActionListMixin): for action in actions: self.add_action(action) + def set_options_manager(self, options_manager: OptionsManager) -> None: + super().set_options_manager(options_manager) + for action in self.actions: + action.set_options_manager(options_manager) + def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]: arg_defs = same_argument_definitions(self.actions) if arg_defs: diff --git a/falyx/action/base_action.py b/falyx/action/base_action.py index 9de6a4d..f132bea 100644 --- a/falyx/action/base_action.py +++ b/falyx/action/base_action.py @@ -36,6 +36,7 @@ from typing import Any, Callable from rich.console import Console from rich.tree import Tree +from falyx.console import console from falyx.context import SharedContext from falyx.debug import register_debug_hooks from falyx.hook_manager import Hook, HookManager, HookType @@ -73,7 +74,7 @@ class BaseAction(ABC): self.inject_into: str = inject_into self._never_prompt: bool = never_prompt self._skip_in_chain: bool = False - self.console = Console(color_system="truecolor") + self.console: Console = console self.options_manager: OptionsManager | None = None if logging_hooks: diff --git a/falyx/action/chained_action.py b/falyx/action/chained_action.py index b78dc7f..a1578a3 100644 --- a/falyx/action/chained_action.py +++ b/falyx/action/chained_action.py @@ -16,6 +16,7 @@ from falyx.exceptions import EmptyChainError 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.themes import OneColors @@ -92,6 +93,11 @@ class ChainedAction(BaseAction, ActionListMixin): for action in actions: self.add_action(action) + def set_options_manager(self, options_manager: OptionsManager) -> None: + super().set_options_manager(options_manager) + for action in self.actions: + action.set_options_manager(options_manager) + def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]: if self.actions: return self.actions[0].get_infer_target() @@ -197,7 +203,7 @@ class ChainedAction(BaseAction, ActionListMixin): def register_hooks_recursively(self, hook_type: HookType, hook: Hook): """Register a hook for all actions and sub-actions.""" - self.hooks.register(hook_type, hook) + super().register_hooks_recursively(hook_type, hook) for action in self.actions: action.register_hooks_recursively(hook_type, hook) diff --git a/falyx/action/confirm_action.py b/falyx/action/confirm_action.py new file mode 100644 index 0000000..e8ee2c8 --- /dev/null +++ b/falyx/action/confirm_action.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any + +from prompt_toolkit import PromptSession +from rich.tree import Tree + +from falyx.action.base_action import BaseAction +from falyx.context import ExecutionContext +from falyx.execution_registry import ExecutionRegistry as er +from falyx.hook_manager import HookType +from falyx.logger import logger +from falyx.prompt_utils import confirm_async, should_prompt_user +from falyx.signals import CancelSignal +from falyx.themes import OneColors +from falyx.validators import word_validator, words_validator + + +class ConfirmType(Enum): + """Enum for different confirmation types.""" + + YES_NO = "yes_no" + YES_CANCEL = "yes_cancel" + YES_NO_CANCEL = "yes_no_cancel" + TYPE_WORD = "type_word" + OK_CANCEL = "ok_cancel" + + @classmethod + def choices(cls) -> list[ConfirmType]: + """Return a list of all hook type choices.""" + return list(cls) + + def __str__(self) -> str: + """Return the string representation of the confirm type.""" + return self.value + + +class ConfirmAction(BaseAction): + """ + Action to confirm an operation with the user. + + There are several ways to confirm an action, such as using a simple + yes/no prompt. You can also use a confirmation type that requires the user + to type a specific word or phrase to confirm the action, or use an OK/Cancel + dialog. + + This action can be used to ensure that the user explicitly agrees to proceed + with an operation. + + Attributes: + name (str): Name of the action. + message (str): The confirmation message to display. + confirm_type (ConfirmType | str): The type of confirmation to use. + Options include YES_NO, YES_CANCEL, YES_NO_CANCEL, TYPE_WORD, and OK_CANCEL. + prompt_session (PromptSession | None): The session to use for input. + confirm (bool): Whether to prompt the user for confirmation. + word (str): The word to type for TYPE_WORD confirmation. + return_last_result (bool): Whether to return the last result of the action. + """ + + def __init__( + self, + name: str, + message: str = "Continue", + confirm_type: ConfirmType | str = ConfirmType.YES_NO, + prompt_session: PromptSession | None = None, + confirm: bool = True, + word: str = "CONFIRM", + return_last_result: bool = False, + inject_last_result: bool = True, + inject_into: str = "last_result", + ): + """ + Initialize the ConfirmAction. + + Args: + message (str): The confirmation message to display. + confirm_type (ConfirmType): The type of confirmation to use. + Options include YES_NO, YES_CANCEL, YES_NO_CANCEL, TYPE_WORD, and OK_CANCEL. + prompt_session (PromptSession | None): The session to use for input. + confirm (bool): Whether to prompt the user for confirmation. + word (str): The word to type for TYPE_WORD confirmation. + return_last_result (bool): Whether to return the last result of the action. + """ + super().__init__( + name=name, + inject_last_result=inject_last_result, + inject_into=inject_into, + ) + self.message = message + self.confirm_type = self._coerce_confirm_type(confirm_type) + self.prompt_session = prompt_session or PromptSession() + self.confirm = confirm + self.word = word + self.return_last_result = return_last_result + + def _coerce_confirm_type(self, confirm_type: ConfirmType | str) -> ConfirmType: + """Coerce the confirm_type to a ConfirmType enum.""" + if isinstance(confirm_type, ConfirmType): + return confirm_type + elif isinstance(confirm_type, str): + return ConfirmType(confirm_type) + return ConfirmType(confirm_type) + + async def _confirm(self) -> bool: + """Confirm the action with the user.""" + match self.confirm_type: + case ConfirmType.YES_NO: + return await confirm_async( + self.message, + prefix="❓ ", + suffix=" [Y/n] > ", + session=self.prompt_session, + ) + case ConfirmType.YES_NO_CANCEL: + answer = await self.prompt_session.prompt_async( + f"❓ {self.message} ([Y]es, [N]o, or [C]ancel to abort): ", + validator=words_validator(["Y", "N", "C"]), + ) + if answer.upper() == "C": + raise CancelSignal(f"Action '{self.name}' was cancelled by the user.") + return answer.upper() == "Y" + case ConfirmType.TYPE_WORD: + answer = await self.prompt_session.prompt_async( + f"❓ {self.message} (type '{self.word}' to confirm or N/n): ", + validator=word_validator(self.word), + ) + return answer.upper().strip() != "N" + case ConfirmType.YES_CANCEL: + answer = await confirm_async( + self.message, + prefix="❓ ", + suffix=" [Y/n] > ", + session=self.prompt_session, + ) + if not answer: + raise CancelSignal(f"Action '{self.name}' was cancelled by the user.") + return answer + case ConfirmType.OK_CANCEL: + answer = await self.prompt_session.prompt_async( + f"❓ {self.message} ([O]k to continue, [C]ancel to abort): ", + validator=words_validator(["O", "C"]), + ) + if answer.upper() == "C": + raise CancelSignal(f"Action '{self.name}' was cancelled by the user.") + return answer.upper() == "O" + case _: + raise ValueError(f"Unknown confirm_type: {self.confirm_type}") + + def get_infer_target(self) -> tuple[None, None]: + return None, None + + async def _run(self, *args, **kwargs) -> Any: + combined_kwargs = self._maybe_inject_last_result(kwargs) + context = ExecutionContext( + name=self.name, args=args, kwargs=combined_kwargs, action=self + ) + context.start_timer() + try: + await self.hooks.trigger(HookType.BEFORE, context) + if ( + not self.confirm + or self.options_manager + and not should_prompt_user( + confirm=self.confirm, options=self.options_manager + ) + ): + logger.debug( + "Skipping confirmation for action '%s' as 'confirm' is False or options manager indicates no prompt.", + self.name, + ) + if self.return_last_result: + result = combined_kwargs[self.inject_into] + else: + result = True + else: + answer = await self._confirm() + if self.return_last_result and answer: + result = combined_kwargs[self.inject_into] + else: + result = answer + logger.debug("Action '%s' confirmed with result: %s", self.name, result) + await self.hooks.trigger(HookType.ON_SUCCESS, context) + return result + 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) -> None: + tree = ( + Tree( + f"[{OneColors.CYAN_b}]ConfirmAction[/]: {self.name}", + guide_style=OneColors.BLUE_b, + ) + if not parent + else parent.add(f"[{OneColors.CYAN_b}]ConfirmAction[/]: {self.name}") + ) + tree.add(f"[bold]Message:[/] {self.message}") + tree.add(f"[bold]Type:[/] {self.confirm_type.value}") + tree.add(f"[bold]Prompt Required:[/] {'Yes' if self.confirm else 'No'}") + if self.confirm_type == ConfirmType.TYPE_WORD: + tree.add(f"[bold]Confirmation Word:[/] {self.word}") + if parent is None: + self.console.print(tree) + + def __str__(self) -> str: + return ( + f"ConfirmAction(name={self.name}, message={self.message}, " + f"confirm_type={self.confirm_type})" + ) diff --git a/falyx/action/load_file_action.py b/falyx/action/load_file_action.py index c28c8d6..fba5a30 100644 --- a/falyx/action/load_file_action.py +++ b/falyx/action/load_file_action.py @@ -80,9 +80,14 @@ class LoadFileAction(BaseAction): def get_infer_target(self) -> tuple[None, None]: return None, None - def load_file(self) -> Any: + async def load_file(self) -> Any: + """Load and parse the file based on its type.""" if self.file_path is None: raise ValueError("file_path must be set before loading a file") + elif not self.file_path.exists(): + raise FileNotFoundError(f"File not found: {self.file_path}") + elif not self.file_path.is_file(): + raise ValueError(f"Path is not a regular file: {self.file_path}") value: Any = None try: if self.file_type == FileType.TEXT: @@ -125,14 +130,7 @@ class LoadFileAction(BaseAction): elif self.inject_last_result and self.last_result: self.file_path = self.last_result - if self.file_path is None: - raise ValueError("file_path must be set before loading a file") - elif not self.file_path.exists(): - raise FileNotFoundError(f"File not found: {self.file_path}") - elif not self.file_path.is_file(): - raise ValueError(f"Path is not a regular file: {self.file_path}") - - result = self.load_file() + result = await self.load_file() await self.hooks.trigger(HookType.ON_SUCCESS, context) return result except Exception as error: diff --git a/falyx/action/menu_action.py b/falyx/action/menu_action.py index d52089a..5866f71 100644 --- a/falyx/action/menu_action.py +++ b/falyx/action/menu_action.py @@ -3,7 +3,6 @@ from typing import Any from prompt_toolkit import PromptSession -from rich.console import Console from rich.table import Table from rich.tree import Tree @@ -33,7 +32,6 @@ class MenuAction(BaseAction): default_selection: str = "", inject_last_result: bool = False, inject_into: str = "last_result", - console: Console | None = None, prompt_session: PromptSession | None = None, never_prompt: bool = False, include_reserved: bool = True, @@ -51,10 +49,6 @@ class MenuAction(BaseAction): self.columns = columns self.prompt_message = prompt_message self.default_selection = default_selection - if isinstance(console, Console): - self.console = console - elif console: - raise ValueError("`console` must be an instance of `rich.console.Console`") self.prompt_session = prompt_session or PromptSession() self.include_reserved = include_reserved self.show_table = show_table @@ -115,7 +109,6 @@ class MenuAction(BaseAction): self.menu_options.keys(), table, default_selection=self.default_selection, - console=self.console, prompt_session=self.prompt_session, prompt_message=self.prompt_message, show_table=self.show_table, diff --git a/falyx/action/prompt_menu_action.py b/falyx/action/prompt_menu_action.py index ba71d13..6a0d0c1 100644 --- a/falyx/action/prompt_menu_action.py +++ b/falyx/action/prompt_menu_action.py @@ -4,7 +4,6 @@ from typing import Any from prompt_toolkit import PromptSession from prompt_toolkit.formatted_text import FormattedText, merge_formatted_text -from rich.console import Console from rich.tree import Tree from falyx.action.base_action import BaseAction @@ -29,7 +28,6 @@ class PromptMenuAction(BaseAction): default_selection: str = "", inject_last_result: bool = False, inject_into: str = "last_result", - console: Console | None = None, prompt_session: PromptSession | None = None, never_prompt: bool = False, include_reserved: bool = True, @@ -43,10 +41,6 @@ class PromptMenuAction(BaseAction): self.menu_options = menu_options self.prompt_message = prompt_message self.default_selection = default_selection - if isinstance(console, Console): - self.console = console - elif console: - raise ValueError("`console` must be an instance of `rich.console.Console`") self.prompt_session = prompt_session or PromptSession() self.include_reserved = include_reserved diff --git a/falyx/action/save_file_action.py b/falyx/action/save_file_action.py new file mode 100644 index 0000000..2b7580a --- /dev/null +++ b/falyx/action/save_file_action.py @@ -0,0 +1,232 @@ +# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed +"""save_file_action.py""" +import csv +import json +import xml.etree.ElementTree as ET +from datetime import datetime +from pathlib import Path +from typing import Any, Literal + +import toml +import yaml +from rich.tree import Tree + +from falyx.action.action_types import FileType +from falyx.action.base_action import BaseAction +from falyx.context import ExecutionContext +from falyx.execution_registry import ExecutionRegistry as er +from falyx.hook_manager import HookType +from falyx.logger import logger +from falyx.themes import OneColors + + +class SaveFileAction(BaseAction): + """ + SaveFileAction saves data to a file in the specified format (e.g., TEXT, JSON, YAML). + Supports overwrite control and integrates with chaining workflows via inject_last_result. + + Supported types: TEXT, JSON, YAML, TOML, CSV, TSV, XML + + If the file exists and overwrite is False, the action will raise a FileExistsError. + """ + + def __init__( + self, + name: str, + file_path: str, + file_type: FileType | str = FileType.TEXT, + mode: Literal["w", "a"] = "w", + inject_last_result: bool = True, + inject_into: str = "data", + overwrite: bool = True, + ): + """ + SaveFileAction allows saving data to a file. + + Args: + name (str): Name of the action. + file_path (str | Path): Path to the file where data will be saved. + file_type (FileType | str): Format to write to (e.g. TEXT, JSON, YAML). + inject_last_result (bool): Whether to inject result from previous action. + inject_into (str): Kwarg name to inject the last result as. + overwrite (bool): Whether to overwrite the file if it exists. + """ + super().__init__( + name=name, inject_last_result=inject_last_result, inject_into=inject_into + ) + self._file_path = self._coerce_file_path(file_path) + self._file_type = self._coerce_file_type(file_type) + self.overwrite = overwrite + self.mode = mode + + @property + def file_path(self) -> Path | None: + """Get the file path as a Path object.""" + return self._file_path + + @file_path.setter + def file_path(self, value: str | Path): + """Set the file path, converting to Path if necessary.""" + self._file_path = self._coerce_file_path(value) + + def _coerce_file_path(self, file_path: str | Path | None) -> Path | None: + """Coerce the file path to a Path object.""" + if isinstance(file_path, Path): + return file_path + elif isinstance(file_path, str): + return Path(file_path) + elif file_path is None: + return None + else: + raise TypeError("file_path must be a string or Path object") + + @property + def file_type(self) -> FileType: + """Get the file type.""" + return self._file_type + + @file_type.setter + def file_type(self, value: FileType | str): + """Set the file type, converting to FileType if necessary.""" + self._file_type = self._coerce_file_type(value) + + def _coerce_file_type(self, file_type: FileType | str) -> FileType: + """Coerce the file type to a FileType enum.""" + if isinstance(file_type, FileType): + return file_type + elif isinstance(file_type, str): + return FileType(file_type) + else: + raise TypeError("file_type must be a FileType enum or string") + + def get_infer_target(self) -> tuple[None, None]: + return None, None + + def _dict_to_xml(self, data: dict, root: ET.Element) -> None: + """Convert a dictionary to XML format.""" + for key, value in data.items(): + if isinstance(value, dict): + sub_element = ET.SubElement(root, key) + self._dict_to_xml(value, sub_element) + elif isinstance(value, list): + for item in value: + item_element = ET.SubElement(root, key) + if isinstance(item, dict): + self._dict_to_xml(item, item_element) + else: + item_element.text = str(item) + else: + element = ET.SubElement(root, key) + element.text = str(value) + + async def save_file(self, data: Any) -> None: + """Save data to the specified file in the desired format.""" + if self.file_path is None: + raise ValueError("file_path must be set before saving a file") + elif self.file_path.exists() and not self.overwrite: + raise FileExistsError(f"File already exists: {self.file_path}") + + try: + if self.file_type == FileType.TEXT: + self.file_path.write_text(data, encoding="UTF-8") + elif self.file_type == FileType.JSON: + self.file_path.write_text(json.dumps(data, indent=4), encoding="UTF-8") + elif self.file_type == FileType.TOML: + self.file_path.write_text(toml.dumps(data), encoding="UTF-8") + elif self.file_type == FileType.YAML: + self.file_path.write_text(yaml.dump(data), encoding="UTF-8") + elif self.file_type == FileType.CSV: + if not isinstance(data, list) or not all( + isinstance(row, list) for row in data + ): + raise ValueError( + f"{self.file_type.name} file type requires a list of lists" + ) + with open( + self.file_path, mode=self.mode, newline="", encoding="UTF-8" + ) as csvfile: + writer = csv.writer(csvfile) + writer.writerows(data) + elif self.file_type == FileType.TSV: + if not isinstance(data, list) or not all( + isinstance(row, list) for row in data + ): + raise ValueError( + f"{self.file_type.name} file type requires a list of lists" + ) + with open( + self.file_path, mode=self.mode, newline="", encoding="UTF-8" + ) as tsvfile: + writer = csv.writer(tsvfile, delimiter="\t") + writer.writerows(data) + elif self.file_type == FileType.XML: + if not isinstance(data, dict): + raise ValueError("XML file type requires data to be a dictionary") + root = ET.Element("root") + self._dict_to_xml(data, root) + tree = ET.ElementTree(root) + tree.write(self.file_path, encoding="UTF-8", xml_declaration=True) + else: + raise ValueError(f"Unsupported file type: {self.file_type}") + + except Exception as error: + logger.error("Failed to save %s: %s", self.file_path.name, error) + raise + + async def _run(self, *args, **kwargs): + combined_kwargs = self._maybe_inject_last_result(kwargs) + data = combined_kwargs.get(self.inject_into) + + context = ExecutionContext( + name=self.name, args=args, kwargs=combined_kwargs, action=self + ) + context.start_timer() + + try: + await self.hooks.trigger(HookType.BEFORE, context) + + await self.save_file(data) + logger.debug("File saved successfully: %s", self.file_path) + + await self.hooks.trigger(HookType.ON_SUCCESS, context) + return str(self.file_path) + + 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.CYAN}]💾 SaveFileAction[/] '{self.name}'" + tree = parent.add(label) if parent else Tree(label) + + tree.add(f"[dim]Path:[/] {self.file_path}") + tree.add(f"[dim]Type:[/] {self.file_type.name}") + tree.add(f"[dim]Overwrite:[/] {self.overwrite}") + + if self.file_path and self.file_path.exists(): + if self.overwrite: + tree.add(f"[{OneColors.LIGHT_YELLOW}]⚠️ File will be overwritten[/]") + else: + tree.add( + f"[{OneColors.DARK_RED}]❌ File exists and overwrite is disabled[/]" + ) + stat = self.file_path.stat() + tree.add(f"[dim]Size:[/] {stat.st_size:,} bytes") + tree.add( + f"[dim]Modified:[/] {datetime.fromtimestamp(stat.st_mtime):%Y-%m-%d %H:%M:%S}" + ) + tree.add( + f"[dim]Created:[/] {datetime.fromtimestamp(stat.st_ctime):%Y-%m-%d %H:%M:%S}" + ) + + if not parent: + self.console.print(tree) + + def __str__(self) -> str: + return f"SaveFileAction(file_path={self.file_path}, file_type={self.file_type})" diff --git a/falyx/action/select_file_action.py b/falyx/action/select_file_action.py index 568d026..873981d 100644 --- a/falyx/action/select_file_action.py +++ b/falyx/action/select_file_action.py @@ -11,7 +11,6 @@ from typing import Any import toml import yaml from prompt_toolkit import PromptSession -from rich.console import Console from rich.tree import Tree from falyx.action.action_types import FileType @@ -51,7 +50,6 @@ class SelectFileAction(BaseAction): style (str): Style for the selection options. suffix_filter (str | None): Restrict to certain file types. return_type (FileType): What to return (path, content, parsed). - console (Console | None): Console instance for output. prompt_session (PromptSession | None): Prompt session for user input. """ @@ -69,7 +67,6 @@ class SelectFileAction(BaseAction): number_selections: int | str = 1, separator: str = ",", allow_duplicates: bool = False, - console: Console | None = None, prompt_session: PromptSession | None = None, ): super().__init__(name) @@ -82,10 +79,6 @@ class SelectFileAction(BaseAction): self.number_selections = number_selections self.separator = separator self.allow_duplicates = allow_duplicates - if isinstance(console, Console): - self.console = console - elif console: - raise ValueError("`console` must be an instance of `rich.console.Console`") self.prompt_session = prompt_session or PromptSession() self.return_type = self._coerce_return_type(return_type) @@ -195,7 +188,6 @@ class SelectFileAction(BaseAction): 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, diff --git a/falyx/action/selection_action.py b/falyx/action/selection_action.py index 39de339..7eda40f 100644 --- a/falyx/action/selection_action.py +++ b/falyx/action/selection_action.py @@ -3,7 +3,6 @@ from typing import Any from prompt_toolkit import PromptSession -from rich.console import Console from rich.tree import Tree from falyx.action.action_types import SelectionReturnType @@ -54,7 +53,6 @@ class SelectionAction(BaseAction): inject_last_result: bool = False, inject_into: str = "last_result", return_type: SelectionReturnType | str = "value", - console: Console | None = None, prompt_session: PromptSession | None = None, never_prompt: bool = False, show_table: bool = True, @@ -70,10 +68,6 @@ class SelectionAction(BaseAction): self.return_type: SelectionReturnType = self._coerce_return_type(return_type) self.title = title self.columns = columns - if isinstance(console, Console): - self.console = console - elif console: - 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 @@ -262,7 +256,6 @@ class SelectionAction(BaseAction): len(self.selections), table, default_selection=effective_default, - console=self.console, prompt_session=self.prompt_session, prompt_message=self.prompt_message, show_table=self.show_table, @@ -306,7 +299,6 @@ class SelectionAction(BaseAction): (self.selections | cancel_option).keys(), table, default_selection=effective_default, - console=self.console, prompt_session=self.prompt_session, prompt_message=self.prompt_message, show_table=self.show_table, diff --git a/falyx/action/user_input_action.py b/falyx/action/user_input_action.py index 926e9cb..a7c8a14 100644 --- a/falyx/action/user_input_action.py +++ b/falyx/action/user_input_action.py @@ -2,7 +2,6 @@ """user_input_action.py""" from prompt_toolkit import PromptSession from prompt_toolkit.validation import Validator -from rich.console import Console from rich.tree import Tree from falyx.action.base_action import BaseAction @@ -20,7 +19,6 @@ class UserInputAction(BaseAction): 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'). @@ -33,7 +31,6 @@ class UserInputAction(BaseAction): prompt_text: str = "Input > ", default_text: str = "", validator: Validator | None = None, - console: Console | None = None, prompt_session: PromptSession | None = None, inject_last_result: bool = False, ): @@ -43,10 +40,6 @@ class UserInputAction(BaseAction): ) self.prompt_text = prompt_text self.validator = validator - if isinstance(console, Console): - self.console = console - 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 diff --git a/falyx/bottom_bar.py b/falyx/bottom_bar.py index 9c7bfcb..5f1c424 100644 --- a/falyx/bottom_bar.py +++ b/falyx/bottom_bar.py @@ -5,8 +5,8 @@ from typing import Any, Callable from prompt_toolkit.formatted_text import HTML, merge_formatted_text from prompt_toolkit.key_binding import KeyBindings -from rich.console import Console +from falyx.console import console from falyx.options_manager import OptionsManager from falyx.themes import OneColors from falyx.utils import CaseInsensitiveDict, chunks @@ -30,7 +30,7 @@ class BottomBar: key_validator: Callable[[str], bool] | None = None, ) -> None: self.columns = columns - self.console = Console(color_system="truecolor") + self.console: Console = console self._named_items: dict[str, Callable[[], HTML]] = {} self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict() self.toggle_keys: list[str] = [] diff --git a/falyx/command.py b/falyx/command.py index c59395b..7353943 100644 --- a/falyx/command.py +++ b/falyx/command.py @@ -23,11 +23,11 @@ from typing import Any, Awaitable, Callable from prompt_toolkit.formatted_text import FormattedText from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator -from rich.console import Console from rich.tree import Tree from falyx.action.action import Action from falyx.action.base_action import BaseAction +from falyx.console import console from falyx.context import ExecutionContext from falyx.debug import register_debug_hooks from falyx.execution_registry import ExecutionRegistry as er @@ -44,8 +44,6 @@ from falyx.signals import CancelSignal from falyx.themes import OneColors from falyx.utils import ensure_async -console = Console(color_system="truecolor") - class Command(BaseModel): """ diff --git a/falyx/config.py b/falyx/config.py index ba8bde6..be26801 100644 --- a/falyx/config.py +++ b/falyx/config.py @@ -11,18 +11,16 @@ from typing import Any, Callable import toml import yaml from pydantic import BaseModel, Field, field_validator, model_validator -from rich.console import Console from falyx.action.action import Action from falyx.action.base_action import BaseAction from falyx.command import Command +from falyx.console import console from falyx.falyx import Falyx from falyx.logger import logger from falyx.retry import RetryPolicy from falyx.themes import OneColors -console = Console(color_system="truecolor") - def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command: if isinstance(obj, (BaseAction, Command)): diff --git a/falyx/console.py b/falyx/console.py new file mode 100644 index 0000000..cbb92ea --- /dev/null +++ b/falyx/console.py @@ -0,0 +1,5 @@ +from rich.console import Console + +from falyx.themes import get_nord_theme + +console = Console(color_system="truecolor", theme=get_nord_theme()) diff --git a/falyx/context.py b/falyx/context.py index cdd424a..6da31d2 100644 --- a/falyx/context.py +++ b/falyx/context.py @@ -24,6 +24,8 @@ from typing import Any from pydantic import BaseModel, ConfigDict, Field from rich.console import Console +from falyx.console import console + class ExecutionContext(BaseModel): """ @@ -83,7 +85,7 @@ class ExecutionContext(BaseModel): index: int | None = None extra: dict[str, Any] = Field(default_factory=dict) - console: Console = Field(default_factory=lambda: Console(color_system="truecolor")) + console: Console = console shared_context: SharedContext | None = None diff --git a/falyx/execution_registry.py b/falyx/execution_registry.py index ddbce66..7f919c6 100644 --- a/falyx/execution_registry.py +++ b/falyx/execution_registry.py @@ -36,6 +36,7 @@ from rich import box from rich.console import Console from rich.table import Table +from falyx.console import console from falyx.context import ExecutionContext from falyx.logger import logger from falyx.themes import OneColors diff --git a/falyx/falyx.py b/falyx/falyx.py index c26a108..6fcd9cc 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -46,6 +46,7 @@ from falyx.action.base_action import BaseAction from falyx.bottom_bar import BottomBar from falyx.command import Command from falyx.completer import FalyxCompleter +from falyx.console import console from falyx.context import ExecutionContext from falyx.debug import log_after, log_before, log_error, log_success from falyx.exceptions import ( @@ -63,7 +64,7 @@ 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 -from falyx.themes import OneColors, get_nord_theme +from falyx.themes import OneColors from falyx.utils import CaseInsensitiveDict, _noop, chunks from falyx.version import __version__ @@ -201,7 +202,7 @@ class Falyx: self.help_command: Command | None = ( self._get_help_command() if include_help_command else None ) - self.console: Console = Console(color_system="truecolor", theme=get_nord_theme()) + self.console: Console = console self.welcome_message: str | Markdown | dict[str, Any] = welcome_message self.exit_message: str | Markdown | dict[str, Any] = exit_message self.hooks: HookManager = HookManager() @@ -513,6 +514,8 @@ class Falyx: bottom_toolbar=self._get_bottom_bar_render(), key_bindings=self.key_bindings, validate_while_typing=True, + interrupt_exception=QuitSignal, + eof_exception=QuitSignal, ) return self._prompt_session diff --git a/falyx/init.py b/falyx/init.py index 65940a8..8e92e17 100644 --- a/falyx/init.py +++ b/falyx/init.py @@ -2,7 +2,7 @@ """init.py""" from pathlib import Path -from rich.console import Console +from falyx.console import console TEMPLATE_TASKS = """\ # This file is used by falyx.yaml to define CLI actions. @@ -98,8 +98,6 @@ commands: aliases: [clean, cleanup] """ -console = Console(color_system="truecolor") - def init_project(name: str) -> None: target = Path(name).resolve() diff --git a/falyx/parser/argument.py b/falyx/parser/argument.py index d08c1be..653937f 100644 --- a/falyx/parser/argument.py +++ b/falyx/parser/argument.py @@ -24,6 +24,7 @@ class Argument: nargs: int | str | None = None # int, '?', '*', '+', None positional: bool = False # True if no leading - or -- in flags resolver: BaseAction | None = None # Action object for the argument + lazy_resolver: bool = False # True if resolver should be called lazily def get_positional_text(self) -> str: """Get the positional text for the argument.""" diff --git a/falyx/parser/command_argument_parser.py b/falyx/parser/command_argument_parser.py index 86969b8..49a5997 100644 --- a/falyx/parser/command_argument_parser.py +++ b/falyx/parser/command_argument_parser.py @@ -9,6 +9,7 @@ from rich.console import Console from rich.markup import escape from falyx.action.base_action import BaseAction +from falyx.console import console from falyx.exceptions import CommandArgumentError from falyx.parser.argument import Argument from falyx.parser.argument_action import ArgumentAction @@ -46,7 +47,7 @@ class CommandArgumentParser: aliases: list[str] | None = None, ) -> None: """Initialize the CommandArgumentParser.""" - self.console = Console(color_system="truecolor") + self.console: Console = console self.command_key: str = command_key self.command_description: str = command_description self.command_style: str = command_style @@ -300,6 +301,7 @@ class CommandArgumentParser: help: str = "", dest: str | None = None, resolver: BaseAction | None = None, + lazy_resolver: bool = False, ) -> None: """Add an argument to the parser. For `ArgumentAction.ACTION`, `nargs` and `type` determine how many and what kind @@ -348,6 +350,10 @@ class CommandArgumentParser: f"Default value '{default}' not in allowed choices: {choices}" ) required = self._determine_required(required, positional, nargs) + if not isinstance(lazy_resolver, bool): + raise CommandArgumentError( + f"lazy_resolver must be a boolean, got {type(lazy_resolver)}" + ) argument = Argument( flags=flags, dest=dest, @@ -360,6 +366,7 @@ class CommandArgumentParser: nargs=nargs, positional=positional, resolver=resolver, + lazy_resolver=lazy_resolver, ) for flag in flags: if flag in self._flag_map: @@ -445,6 +452,7 @@ class CommandArgumentParser: result: dict[str, Any], positional_args: list[Argument], consumed_positional_indicies: set[int], + from_validate: bool = False, ) -> int: remaining_positional_args = [ (j, spec) @@ -508,12 +516,13 @@ class CommandArgumentParser: assert isinstance( spec.resolver, BaseAction ), "resolver should be an instance of BaseAction" - try: - result[spec.dest] = await spec.resolver(*typed) - except Exception as error: - raise CommandArgumentError( - f"[{spec.dest}] Action failed: {error}" - ) from error + if not spec.lazy_resolver or not from_validate: + try: + result[spec.dest] = await spec.resolver(*typed) + except Exception as error: + raise CommandArgumentError( + f"[{spec.dest}] Action failed: {error}" + ) from error elif not typed and spec.default: result[spec.dest] = spec.default elif spec.action == ArgumentAction.APPEND: @@ -657,21 +666,25 @@ class CommandArgumentParser: if not typed_values and spec.nargs not in ("*", "?"): choices = [] if spec.default: - choices.append(f"default={spec.default!r}") + choices.append(f"default={spec.default}") if spec.choices: - choices.append(f"choices={spec.choices!r}") + choices.append(f"choices={spec.choices}") if choices: choices_text = ", ".join(choices) raise CommandArgumentError( f"Argument '{spec.dest}' requires a value. {choices_text}" ) - if spec.nargs is None: + elif spec.nargs is None: + try: + raise CommandArgumentError( + f"Enter a {spec.type.__name__} value for '{spec.dest}'" + ) + except AttributeError: + raise CommandArgumentError(f"Enter a value for '{spec.dest}'") + else: raise CommandArgumentError( - f"Enter a {spec.type.__name__} value for '{spec.dest}'" + f"Argument '{spec.dest}' requires a value. Expected {spec.nargs} values." ) - raise CommandArgumentError( - f"Argument '{spec.dest}' requires a value. Expected {spec.nargs} values." - ) if spec.nargs in (None, 1, "?") and spec.action != ArgumentAction.APPEND: result[spec.dest] = ( typed_values[0] if len(typed_values) == 1 else typed_values @@ -705,6 +718,7 @@ class CommandArgumentParser: result, positional_args, consumed_positional_indices, + from_validate=from_validate, ) i += args_consumed return i @@ -746,13 +760,19 @@ class CommandArgumentParser: continue if spec.required and not result.get(spec.dest): help_text = f" help: {spec.help}" if spec.help else "" + if ( + spec.action == ArgumentAction.ACTION + and spec.lazy_resolver + and from_validate + ): + continue # Lazy resolvers are not validated here raise CommandArgumentError( - f"Missing required argument {spec.dest}: {spec.get_choice_text()}{help_text}" + f"Missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}" ) if spec.choices and result.get(spec.dest) not in spec.choices: raise CommandArgumentError( - f"Invalid value for {spec.dest}: must be one of {spec.choices}" + f"Invalid value for '{spec.dest}': must be one of {{{', '.join(spec.choices)}}}" ) if spec.action == ArgumentAction.ACTION: @@ -761,23 +781,23 @@ class CommandArgumentParser: if isinstance(spec.nargs, int) and spec.nargs > 1: assert isinstance( result.get(spec.dest), list - ), f"Invalid value for {spec.dest}: expected a list" + ), f"Invalid value for '{spec.dest}': expected a list" if not result[spec.dest] and not spec.required: continue if spec.action == ArgumentAction.APPEND: for group in result[spec.dest]: if len(group) % spec.nargs != 0: raise CommandArgumentError( - f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}" + f"Invalid number of values for '{spec.dest}': expected a multiple of {spec.nargs}" ) elif spec.action == ArgumentAction.EXTEND: if len(result[spec.dest]) % spec.nargs != 0: raise CommandArgumentError( - f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}" + f"Invalid number of values for '{spec.dest}': expected a multiple of {spec.nargs}" ) elif len(result[spec.dest]) != spec.nargs: raise CommandArgumentError( - f"Invalid number of values for {spec.dest}: expected {spec.nargs}, got {len(result[spec.dest])}" + f"Invalid number of values for '{spec.dest}': expected {spec.nargs}, got {len(result[spec.dest])}" ) result.pop("help", None) diff --git a/falyx/parser/utils.py b/falyx/parser/utils.py index fc9e7c1..d739fac 100644 --- a/falyx/parser/utils.py +++ b/falyx/parser/utils.py @@ -37,7 +37,8 @@ def coerce_enum(value: Any, enum_type: EnumMeta) -> Any: coerced_value = base_type(value) return enum_type(coerced_value) except (ValueError, TypeError): - raise ValueError(f"Value '{value}' could not be coerced to enum type {enum_type}") + values = [str(enum.value) for enum in enum_type] + raise ValueError(f"'{value}' should be one of {{{', '.join(values)}}}") from None def coerce_value(value: str, target_type: type) -> Any: @@ -57,7 +58,7 @@ def coerce_value(value: str, target_type: type) -> Any: return coerce_value(value, arg) except Exception: continue - raise ValueError(f"Value '{value}' could not be coerced to any of {args!r}") + raise ValueError(f"Value '{value}' could not be coerced to any of {args}") if isinstance(target_type, EnumMeta): return coerce_enum(value, target_type) diff --git a/falyx/selection.py b/falyx/selection.py index 1eea3af..e3df731 100644 --- a/falyx/selection.py +++ b/falyx/selection.py @@ -5,10 +5,10 @@ from typing import Any, Callable, KeysView, Sequence from prompt_toolkit import PromptSession from rich import box -from rich.console import Console from rich.markup import escape from rich.table import Table +from falyx.console import console from falyx.themes import OneColors from falyx.utils import CaseInsensitiveDict, chunks from falyx.validators import MultiIndexValidator, MultiKeyValidator @@ -267,7 +267,6 @@ async def prompt_for_index( *, min_index: int = 0, default_selection: str = "", - console: Console | None = None, prompt_session: PromptSession | None = None, prompt_message: str = "Select an option > ", show_table: bool = True, @@ -277,7 +276,6 @@ async def prompt_for_index( cancel_key: str = "", ) -> int | list[int]: prompt_session = prompt_session or PromptSession() - console = console or Console(color_system="truecolor") if show_table: console.print(table, justify="center") @@ -307,7 +305,6 @@ async def prompt_for_selection( table: Table, *, default_selection: str = "", - console: Console | None = None, prompt_session: PromptSession | None = None, prompt_message: str = "Select an option > ", show_table: bool = True, @@ -318,7 +315,6 @@ async def prompt_for_selection( ) -> 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") if show_table: console.print(table, justify="center") @@ -342,7 +338,6 @@ async def select_value_from_list( title: str, selections: Sequence[str], *, - console: Console | None = None, prompt_session: PromptSession | None = None, prompt_message: str = "Select an option > ", default_selection: str = "", @@ -381,13 +376,11 @@ async def select_value_from_list( highlight=highlight, ) prompt_session = prompt_session or PromptSession() - console = console or Console(color_system="truecolor") selection_index = await prompt_for_index( len(selections) - 1, table, default_selection=default_selection, - console=console, prompt_session=prompt_session, prompt_message=prompt_message, number_selections=number_selections, @@ -405,7 +398,6 @@ async def select_key_from_dict( selections: dict[str, SelectionOption], table: Table, *, - console: Console | None = None, prompt_session: PromptSession | None = None, prompt_message: str = "Select an option > ", default_selection: str = "", @@ -416,7 +408,6 @@ async def select_key_from_dict( ) -> 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") console.print(table, justify="center") @@ -424,7 +415,6 @@ async def select_key_from_dict( selections.keys(), table, default_selection=default_selection, - console=console, prompt_session=prompt_session, prompt_message=prompt_message, number_selections=number_selections, @@ -438,7 +428,6 @@ async def select_value_from_dict( selections: dict[str, SelectionOption], table: Table, *, - console: Console | None = None, prompt_session: PromptSession | None = None, prompt_message: str = "Select an option > ", default_selection: str = "", @@ -449,7 +438,6 @@ async def select_value_from_dict( ) -> 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") console.print(table, justify="center") @@ -457,7 +445,6 @@ async def select_value_from_dict( selections.keys(), table, default_selection=default_selection, - console=console, prompt_session=prompt_session, prompt_message=prompt_message, number_selections=number_selections, @@ -475,7 +462,6 @@ async def get_selection_from_dict_menu( title: str, selections: dict[str, SelectionOption], *, - console: Console | None = None, prompt_session: PromptSession | None = None, prompt_message: str = "Select an option > ", default_selection: str = "", @@ -493,7 +479,6 @@ async def get_selection_from_dict_menu( return await select_value_from_dict( selections=selections, table=table, - console=console, prompt_session=prompt_session, prompt_message=prompt_message, default_selection=default_selection, diff --git a/falyx/validators.py b/falyx/validators.py index 672192a..19b8ceb 100644 --- a/falyx/validators.py +++ b/falyx/validators.py @@ -47,6 +47,30 @@ def yes_no_validator() -> Validator: return Validator.from_callable(validate, error_message="Enter 'Y' or 'n'.") +def words_validator(keys: Sequence[str] | KeysView[str]) -> Validator: + """Validator for specific word inputs.""" + + def validate(text: str) -> bool: + if text.upper() not in [key.upper() for key in keys]: + return False + return True + + return Validator.from_callable( + validate, error_message=f"Invalid input. Choices: {{{', '.join(keys)}}}." + ) + + +def word_validator(word: str) -> Validator: + """Validator for specific word inputs.""" + + def validate(text: str) -> bool: + if text.upper().strip() == "N": + return True + return text.upper().strip() == word.upper() + + return Validator.from_callable(validate, error_message=f"Enter '{word}' or 'N'.") + + class MultiIndexValidator(Validator): def __init__( self, diff --git a/falyx/version.py b/falyx/version.py index 5b3ab24..f1fe4d9 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.56" +__version__ = "0.1.57" diff --git a/pyproject.toml b/pyproject.toml index edf7800..82a7374 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "falyx" -version = "0.1.56" +version = "0.1.57" description = "Reliable and introspectable async CLI action framework." authors = ["Roland Thomas Jr "] license = "MIT" diff --git a/tests/test_parsers/test_multiple_positional.py b/tests/test_parsers/test_multiple_positional.py index aac07f9..4a63c69 100644 --- a/tests/test_parsers/test_multiple_positional.py +++ b/tests/test_parsers/test_multiple_positional.py @@ -23,3 +23,18 @@ async def test_multiple_positional_with_default(): args = await parser.parse_args(["a", "b", "c"]) assert args["files"] == ["a", "b", "c"] assert args["mode"] == "edit" + + +@pytest.mark.asyncio +async def test_multiple_positional_with_double_default(): + parser = CommandArgumentParser() + parser.add_argument("files", nargs="+", default=["a", "b", "c"]) + parser.add_argument("mode", choices=["edit", "view"], default="edit") + + args = await parser.parse_args() + assert args["files"] == ["a", "b", "c"] + assert args["mode"] == "edit" + + args = await parser.parse_args(["a", "b"]) + assert args["files"] == ["a", "b"] + assert args["mode"] == "edit"