From 8a0a45e17fd6db365c686755eddbb7849dd75cd1 Mon Sep 17 00:00:00 2001 From: Roland Thomas Date: Sun, 27 Jul 2025 14:00:51 -0400 Subject: [PATCH] feat(falyx): add persistent prompt history, multiline input support, and new enum tests - Added `enable_prompt_history` & `prompt_history_base_dir` to Falyx, using FileHistory to persist inputs to `~/.{program}_history` - Added `multiline` option to UserInputAction and passed through to PromptSession - Updated examples (`argument_examples.py`, `confirm_example.py`) to enable prompt history and add TLDR examples - Improved CLI usage tips for clarity (`[COMMAND]` instead of `[KEY]`) - Added `test_action_types.py` and expanded `test_main.py` for init and parser coverage - Bumped version to 0.1.76 --- examples/argument_examples.py | 1 + examples/confirm_example.py | 8 +- falyx/action/user_input_action.py | 5 +- falyx/falyx.py | 35 ++++-- falyx/version.py | 2 +- pyproject.toml | 2 +- tests/test_actions/test_action_types.py | 145 ++++++++++++++++++++++++ tests/test_main.py | 84 +++++++++++++- 8 files changed, 265 insertions(+), 17 deletions(-) create mode 100644 tests/test_actions/test_action_types.py diff --git a/examples/argument_examples.py b/examples/argument_examples.py index 2af9889..b0ec775 100644 --- a/examples/argument_examples.py +++ b/examples/argument_examples.py @@ -82,6 +82,7 @@ flx = Falyx( program="argument_examples.py", hide_menu_table=True, show_placeholder_menu=True, + enable_prompt_history=True, ) flx.add_command( diff --git a/examples/confirm_example.py b/examples/confirm_example.py index 44a5696..2780684 100644 --- a/examples/confirm_example.py +++ b/examples/confirm_example.py @@ -101,10 +101,16 @@ def dog_config(parser: CommandArgumentParser) -> None: lazy_resolver=False, help="List of dogs to process.", ) + parser.add_tldr_examples( + [ + ("max", "Process the dog named Max"), + ("bella buddy max", "Process the dogs named Bella, Buddy, and Max"), + ] + ) async def main(): - flx = Falyx("Save Dogs Example") + flx = Falyx("Save Dogs Example", program="confirm_example.py") flx.add_command( key="D", diff --git a/falyx/action/user_input_action.py b/falyx/action/user_input_action.py index 8f62b03..e784495 100644 --- a/falyx/action/user_input_action.py +++ b/falyx/action/user_input_action.py @@ -63,6 +63,7 @@ class UserInputAction(BaseAction): *, prompt_message: str = "Input > ", default_text: str = "", + multiline: bool = False, validator: Validator | None = None, prompt_session: PromptSession | None = None, inject_last_result: bool = False, @@ -72,11 +73,12 @@ class UserInputAction(BaseAction): inject_last_result=inject_last_result, ) self.prompt_message = prompt_message + self.default_text = default_text + self.multiline = multiline self.validator = validator self.prompt_session = prompt_session or PromptSession( interrupt_exception=CancelSignal ) - self.default_text = default_text def get_infer_target(self) -> tuple[None, None]: return None, None @@ -100,6 +102,7 @@ class UserInputAction(BaseAction): rich_text_to_prompt_text(prompt_message), validator=self.validator, default=kwargs.get("default_text", self.default_text), + multiline=self.multiline, ) context.result = answer await self.hooks.trigger(HookType.ON_SUCCESS, context) diff --git a/falyx/falyx.py b/falyx/falyx.py index 0c574d2..f707cb4 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -27,11 +27,13 @@ import sys from argparse import ArgumentParser, Namespace, _SubParsersAction from difflib import get_close_matches from functools import cached_property +from pathlib import Path from random import choice from typing import Any, Callable from prompt_toolkit import PromptSession from prompt_toolkit.formatted_text import StyleAndTextTuples +from prompt_toolkit.history import FileHistory from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.validation import ValidationError @@ -126,7 +128,6 @@ class Falyx: title: str | Markdown = "Menu", *, program: str | None = "falyx", - program_style: str = OneColors.WHITE, usage: str | None = None, description: str | None = "Falyx CLI - Run structured async command workflows.", epilog: str | None = None, @@ -148,10 +149,12 @@ class Falyx: custom_table: Callable[[Falyx], Table] | Table | None = None, hide_menu_table: bool = False, show_placeholder_menu: bool = False, + prompt_history_base_dir: Path = Path.home(), + enable_prompt_history: bool = False, ) -> None: """Initializes the Falyx object.""" self.title: str | Markdown = title - self.program: str | None = program + self.program: str = program or "" self.usage: str | None = usage self.description: str | None = description self.epilog: str | None = epilog @@ -184,6 +187,14 @@ class Falyx: self.help_command: Command | None = ( self._get_help_command() if include_help_command else None ) + if enable_prompt_history: + program = (self.program or "falyx").split(".")[0].replace(" ", "_") + self.history_path: Path = ( + Path(prompt_history_base_dir) / f".{program}_history" + ) + self.history: FileHistory | None = FileHistory(self.history_path) + else: + self.history = None @property def is_cli_mode(self) -> bool: @@ -334,30 +345,31 @@ class Falyx: def get_tip(self) -> str: program = f"{self.program} run " if self.is_cli_mode else "" tips = [ - f"Tip: Use '{program}?[KEY]' to preview a command.", - f"Tip: Use '{program}?' alone to list all commands at any time.", + f"Tip: Use '{program}?[COMMAND]' to preview a command.", "Tip: Every command supports aliases—try abbreviating the name!", f"Tip: Use '{program}H' to reopen this help menu anytime.", - f"Tip: '{program}[KEY] --help' prints a detailed help message.", - "Tip: Mix CLI and menu mode—commands run the same way in both.", + f"Tip: '{program}[COMMAND] --help' prints a detailed help message.", + "Tip: [bold]CLI[/] and [bold]Menu[/] mode—commands run the same way in both.", f"Tip: Use '{self.program} --never-prompt' to disable all prompts for the [bold italic]entire menu session[/].", f"Tip: Use '{self.program} --verbose' to enable debug logging for a menu session.", f"Tip: '{self.program} --debug-hooks' will trace every before/after hook in action.", - f"Tip: Run commands directly from the CLI: '{self.program} run [KEY] [OPTIONS]'.", + f"Tip: Run commands directly from the CLI: '{self.program} run [COMMAND] [OPTIONS]'.", ] if self.is_cli_mode: tips.extend( [ - f"Tip: Use '{self.program} --never-prompt run [KEY]' to disable all prompts for [bold italic]just this command[/].", - f"Tip: Use '{self.program} run --skip-confirm [KEY]' to skip confirmations.", - f"Tip: Use '{self.program} run --summary [KEY]' to print a post-run summary.", - f"Tip: Use '{self.program} --verbose run [KEY]' to enable debug logging for any run.", + f"Tip: Use '{self.program} run ?' to list all commands at any time.", + f"Tip: Use '{self.program} --never-prompt run [COMMAND] [OPTIONS]' to disable all prompts for [bold italic]just this command[/].", + f"Tip: Use '{self.program} run --skip-confirm [COMMAND] [OPTIONS]' to skip confirmations.", + f"Tip: Use '{self.program} run --summary [COMMAND] [OPTIONS]' to print a post-run summary.", + f"Tip: Use '{self.program} --verbose run [COMMAND] [OPTIONS]' to enable debug logging for any run.", "Tip: Use '--skip-confirm' for automation scripts where no prompts are wanted.", ] ) else: tips.extend( [ + "Tip: Use '[?]' alone to list all commands at any time.", "Tip: '[CTRL+KEY]' toggles are available in menu mode for quick switches.", "Tip: '[Y]' opens the command history viewer.", "Tip: Use '[X]' in menu mode to exit.", @@ -514,6 +526,7 @@ class Falyx: placeholder = self.build_placeholder_menu() self._prompt_session = PromptSession( message=self.prompt, + history=self.history, multiline=False, completer=self._get_completer(), validator=CommandValidator(self, self._get_validator_error_message()), diff --git a/falyx/version.py b/falyx/version.py index 0077fa1..a3f3cf2 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.75" +__version__ = "0.1.76" diff --git a/pyproject.toml b/pyproject.toml index 250d305..99ab8a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "falyx" -version = "0.1.75" +version = "0.1.76" description = "Reliable and introspectable async CLI action framework." authors = ["Roland Thomas Jr "] license = "MIT" diff --git a/tests/test_actions/test_action_types.py b/tests/test_actions/test_action_types.py new file mode 100644 index 0000000..50293aa --- /dev/null +++ b/tests/test_actions/test_action_types.py @@ -0,0 +1,145 @@ +import pytest + +from falyx.action.action_types import ConfirmType, FileType, SelectionReturnType + + +def test_file_type_enum(): + """Test if the FileType enum has all expected members.""" + assert FileType.TEXT.value == "text" + assert FileType.PATH.value == "path" + assert FileType.JSON.value == "json" + assert FileType.TOML.value == "toml" + assert FileType.YAML.value == "yaml" + assert FileType.CSV.value == "csv" + assert FileType.TSV.value == "tsv" + assert FileType.XML.value == "xml" + + assert str(FileType.TEXT) == "text" + + +def test_file_type_choices(): + """Test if the FileType choices method returns all enum members.""" + choices = FileType.choices() + assert len(choices) == 8 + assert all(isinstance(choice, FileType) for choice in choices) + + +def test_file_type_missing(): + """Test if the _missing_ method raises ValueError for invalid values.""" + with pytest.raises(ValueError, match="Invalid FileType: 'invalid'"): + FileType._missing_("invalid") + + with pytest.raises(ValueError, match="Invalid FileType: 123"): + FileType._missing_(123) + + +def test_file_type_aliases(): + """Test if the _get_alias method returns correct aliases.""" + assert FileType._get_alias("file") == "path" + assert FileType._get_alias("filepath") == "path" + assert FileType._get_alias("unknown") == "unknown" + + +def test_file_type_missing_aliases(): + """Test if the _missing_ method handles aliases correctly.""" + assert FileType._missing_("file") == FileType.PATH + assert FileType._missing_("filepath") == FileType.PATH + + with pytest.raises(ValueError, match="Invalid FileType: 'unknown'"): + FileType._missing_("unknown") + + +def test_confirm_type_enum(): + """Test if the ConfirmType enum has all expected members.""" + assert ConfirmType.YES_NO.value == "yes_no" + assert ConfirmType.YES_CANCEL.value == "yes_cancel" + assert ConfirmType.YES_NO_CANCEL.value == "yes_no_cancel" + assert ConfirmType.TYPE_WORD.value == "type_word" + assert ConfirmType.TYPE_WORD_CANCEL.value == "type_word_cancel" + assert ConfirmType.OK_CANCEL.value == "ok_cancel" + assert ConfirmType.ACKNOWLEDGE.value == "acknowledge" + + assert str(ConfirmType.YES_NO) == "yes_no" + + +def test_confirm_type_choices(): + """Test if the ConfirmType choices method returns all enum members.""" + choices = ConfirmType.choices() + assert len(choices) == 7 + assert all(isinstance(choice, ConfirmType) for choice in choices) + + +def test_confirm_type_missing(): + """Test if the _missing_ method raises ValueError for invalid values.""" + with pytest.raises(ValueError, match="Invalid ConfirmType: 'invalid'"): + ConfirmType._missing_("invalid") + + with pytest.raises(ValueError, match="Invalid ConfirmType: 123"): + ConfirmType._missing_(123) + + +def test_confirm_type_aliases(): + """Test if the _get_alias method returns correct aliases.""" + assert ConfirmType._get_alias("yes") == "yes_no" + assert ConfirmType._get_alias("ok") == "ok_cancel" + assert ConfirmType._get_alias("type") == "type_word" + assert ConfirmType._get_alias("word") == "type_word" + assert ConfirmType._get_alias("word_cancel") == "type_word_cancel" + assert ConfirmType._get_alias("ack") == "acknowledge" + + +def test_confirm_type_missing_aliases(): + """Test if the _missing_ method handles aliases correctly.""" + assert ConfirmType("yes") == ConfirmType.YES_NO + assert ConfirmType("ok") == ConfirmType.OK_CANCEL + assert ConfirmType("word") == ConfirmType.TYPE_WORD + assert ConfirmType("ack") == ConfirmType.ACKNOWLEDGE + + with pytest.raises(ValueError, match="Invalid ConfirmType: 'unknown'"): + ConfirmType._missing_("unknown") + + +def test_selection_return_type_enum(): + """Test if the SelectionReturnType enum has all expected members.""" + assert SelectionReturnType.KEY.value == "key" + assert SelectionReturnType.VALUE.value == "value" + assert SelectionReturnType.DESCRIPTION.value == "description" + assert SelectionReturnType.DESCRIPTION_VALUE.value == "description_value" + assert SelectionReturnType.ITEMS.value == "items" + + assert str(SelectionReturnType.KEY) == "key" + + +def test_selection_return_type_choices(): + """Test if the SelectionReturnType choices method returns all enum members.""" + choices = SelectionReturnType.choices() + assert len(choices) == 5 + assert all(isinstance(choice, SelectionReturnType) for choice in choices) + + +def test_selection_return_type_missing(): + """Test if the _missing_ method raises ValueError for invalid values.""" + with pytest.raises(ValueError, match="Invalid SelectionReturnType: 'invalid'"): + SelectionReturnType._missing_("invalid") + + with pytest.raises(ValueError, match="Invalid SelectionReturnType: 123"): + SelectionReturnType._missing_(123) + + +def test_selection_return_type_aliases(): + """Test if the _get_alias method returns correct aliases.""" + assert SelectionReturnType._get_alias("desc") == "description" + assert SelectionReturnType._get_alias("desc_value") == "description_value" + assert SelectionReturnType._get_alias("unknown") == "unknown" + + +def test_selection_return_type_missing_aliases(): + """Test if the _missing_ method handles aliases correctly.""" + assert SelectionReturnType._missing_("desc") == SelectionReturnType.DESCRIPTION + assert ( + SelectionReturnType._missing_("desc_value") + == SelectionReturnType.DESCRIPTION_VALUE + ) + + with pytest.raises(ValueError, match="Invalid SelectionReturnType: 'unknown'"): + SelectionReturnType._missing_("unknown") diff --git a/tests/test_main.py b/tests/test_main.py index f9b37bd..58add25 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,11 +1,40 @@ -import os import shutil import sys +import tempfile +from argparse import ArgumentParser, Namespace, _SubParsersAction from pathlib import Path import pytest -from falyx.__main__ import bootstrap, find_falyx_config, main +from falyx.__main__ import ( + bootstrap, + find_falyx_config, + get_parsers, + init_callback, + init_config, + main, +) +from falyx.parser import CommandArgumentParser + + +@pytest.fixture(autouse=True) +def fake_home(monkeypatch): + """Redirect Path.home() to a temporary directory for all tests.""" + temp_home = Path(tempfile.mkdtemp()) + monkeypatch.setattr(Path, "home", lambda: temp_home) + yield temp_home + shutil.rmtree(temp_home, ignore_errors=True) + + +@pytest.fixture(autouse=True) +def setup_teardown(): + """Fixture to set up and tear down the environment for each test.""" + cwd = Path.cwd() + yield + for file in cwd.glob("falyx.yaml"): + file.unlink(missing_ok=True) + for file in cwd.glob("falyx.toml"): + file.unlink(missing_ok=True) def test_find_falyx_config(): @@ -50,3 +79,54 @@ def test_bootstrap_with_global_config(): assert str(config_file.parent) in sys.path config_file.unlink() sys.path = sys_path_before + + +@pytest.mark.asyncio +async def test_init_config(): + """Test if the init_config function adds the correct argument.""" + parser = CommandArgumentParser() + init_config(parser) + args = await parser.parse_args(["test_project"]) + assert args["name"] == "test_project" + + # Test with default value + args = await parser.parse_args([]) + assert args["name"] == "." + + +def test_init_callback(tmp_path): + """Test if the init_callback function works correctly.""" + # Test project initialization + args = Namespace(command="init", name=str(tmp_path)) + init_callback(args) + assert (tmp_path / "falyx.yaml").exists() + + +def test_init_global_callback(): + # Test global initialization + args = Namespace(command="init_global") + init_callback(args) + assert (Path.home() / ".config" / "falyx" / "tasks.py").exists() + assert (Path.home() / ".config" / "falyx" / "falyx.yaml").exists() + + +def test_get_parsers(): + """Test if the get_parsers function returns the correct parsers.""" + root_parser, subparsers = get_parsers() + assert isinstance(root_parser, ArgumentParser) + assert isinstance(subparsers, _SubParsersAction) + + # Check if the 'init' command is available + init_parser = subparsers.choices.get("init") + assert init_parser is not None + assert "name" == init_parser._get_positional_actions()[0].dest + + +def test_main(): + """Test if the main function runs with the correct arguments.""" + + sys.argv = ["falyx", "run", "?"] + + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0