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
			
			
This commit is contained in:
		| @@ -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( | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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()), | ||||
|   | ||||
| @@ -1 +1 @@ | ||||
| __version__ = "0.1.75" | ||||
| __version__ = "0.1.76" | ||||
|   | ||||
| @@ -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 <roland@rtj.dev>"] | ||||
| license = "MIT" | ||||
|   | ||||
							
								
								
									
										145
									
								
								tests/test_actions/test_action_types.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								tests/test_actions/test_action_types.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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") | ||||
| @@ -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 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user