Compare commits
	
		
			2 Commits
		
	
	
		
			3b2c33d28f
			...
			a25888f316
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| a25888f316 | |||
| 8e306b9eaf | 
| @@ -1,5 +1,6 @@ | |||||||
| import asyncio | import asyncio | ||||||
| from enum import Enum | from enum import Enum | ||||||
|  | from pathlib import Path | ||||||
|  |  | ||||||
| from falyx import Falyx | from falyx import Falyx | ||||||
| from falyx.action import Action | from falyx.action import Action | ||||||
| @@ -21,13 +22,14 @@ async def test_args( | |||||||
|     service: str, |     service: str, | ||||||
|     place: Place = Place.NEW_YORK, |     place: Place = Place.NEW_YORK, | ||||||
|     region: str = "us-east-1", |     region: str = "us-east-1", | ||||||
|  |     path: Path | None = None, | ||||||
|     tag: str | None = None, |     tag: str | None = None, | ||||||
|     verbose: bool | None = None, |     verbose: bool | None = None, | ||||||
|     number: int | None = None, |     number: int | None = None, | ||||||
| ) -> str: | ) -> str: | ||||||
|     if verbose: |     if verbose: | ||||||
|         print(f"Deploying {service}:{tag}:{number} to {region} at {place}...") |         print(f"Deploying {service}:{tag}:{number} to {region} at {place} from {path}...") | ||||||
|     return f"{service}:{tag}:{number} deployed to {region} at {place}" |     return f"{service}:{tag}:{number} deployed to {region} at {place} from {path}." | ||||||
|  |  | ||||||
|  |  | ||||||
| def default_config(parser: CommandArgumentParser) -> None: | def default_config(parser: CommandArgumentParser) -> None: | ||||||
| @@ -52,6 +54,11 @@ def default_config(parser: CommandArgumentParser) -> None: | |||||||
|         help="Deployment region.", |         help="Deployment region.", | ||||||
|         choices=["us-east-1", "us-west-2", "eu-west-1"], |         choices=["us-east-1", "us-west-2", "eu-west-1"], | ||||||
|     ) |     ) | ||||||
|  |     parser.add_argument( | ||||||
|  |         "--path", | ||||||
|  |         type=Path, | ||||||
|  |         help="Path to the configuration file.", | ||||||
|  |     ) | ||||||
|     parser.add_argument( |     parser.add_argument( | ||||||
|         "--verbose", |         "--verbose", | ||||||
|         action="store_bool_optional", |         action="store_bool_optional", | ||||||
|   | |||||||
| @@ -21,12 +21,14 @@ Key Features: | |||||||
| - Columnar layout with automatic width scaling | - Columnar layout with automatic width scaling | ||||||
| - Optional integration with `OptionsManager` for dynamic state toggling | - Optional integration with `OptionsManager` for dynamic state toggling | ||||||
|  |  | ||||||
| Usage Example: | Example: | ||||||
|  |     ``` | ||||||
|     bar = BottomBar(columns=3) |     bar = BottomBar(columns=3) | ||||||
|     bar.add_static("env", "ENV: dev") |     bar.add_static("env", "ENV: dev") | ||||||
|     bar.add_toggle("d", "Debug", get_debug, toggle_debug) |     bar.add_toggle("d", "Debug", get_debug, toggle_debug) | ||||||
|     bar.add_value_tracker("attempts", "Retries", get_retry_count) |     bar.add_value_tracker("attempts", "Retries", get_retry_count) | ||||||
|     bar.render() |     bar.render() | ||||||
|  |     ``` | ||||||
|  |  | ||||||
| Used by Falyx to provide a persistent UI element showing toggles, system state, | Used by Falyx to provide a persistent UI element showing toggles, system state, | ||||||
| and runtime telemetry below the input prompt. | and runtime telemetry below the input prompt. | ||||||
|   | |||||||
| @@ -17,6 +17,7 @@ Integrated with the `Falyx.prompt_session` to enhance the interactive experience | |||||||
|  |  | ||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
|  |  | ||||||
|  | import os | ||||||
| import shlex | import shlex | ||||||
| from typing import TYPE_CHECKING, Iterable | from typing import TYPE_CHECKING, Iterable | ||||||
|  |  | ||||||
| @@ -69,7 +70,9 @@ class FalyxCompleter(Completer): | |||||||
|  |  | ||||||
|         if not tokens or (len(tokens) == 1 and not cursor_at_end_of_token): |         if not tokens or (len(tokens) == 1 and not cursor_at_end_of_token): | ||||||
|             # Suggest command keys and aliases |             # Suggest command keys and aliases | ||||||
|             yield from self._suggest_commands(tokens[0] if tokens else "") |             stub = tokens[0] if tokens else "" | ||||||
|  |             suggestions = [c.text for c in self._suggest_commands(stub)] | ||||||
|  |             yield from self._yield_lcp_completions(suggestions, stub) | ||||||
|             return |             return | ||||||
|  |  | ||||||
|         # Identify command |         # Identify command | ||||||
| @@ -83,21 +86,10 @@ class FalyxCompleter(Completer): | |||||||
|         stub = "" if cursor_at_end_of_token else tokens[-1] |         stub = "" if cursor_at_end_of_token else tokens[-1] | ||||||
|  |  | ||||||
|         try: |         try: | ||||||
|             if not command.arg_parser: |  | ||||||
|                 return |  | ||||||
|             suggestions = command.arg_parser.suggest_next( |             suggestions = command.arg_parser.suggest_next( | ||||||
|                 parsed_args + ([stub] if stub else []), cursor_at_end_of_token |                 parsed_args + ([stub] if stub else []), cursor_at_end_of_token | ||||||
|             ) |             ) | ||||||
|             for suggestion in suggestions: |             yield from self._yield_lcp_completions(suggestions, stub) | ||||||
|                 if suggestion.startswith(stub): |  | ||||||
|                     if len(suggestion.split()) > 1: |  | ||||||
|                         yield Completion( |  | ||||||
|                             f'"{suggestion}"', |  | ||||||
|                             start_position=-len(stub), |  | ||||||
|                             display=suggestion, |  | ||||||
|                         ) |  | ||||||
|                     else: |  | ||||||
|                         yield Completion(suggestion, start_position=-len(stub)) |  | ||||||
|         except Exception: |         except Exception: | ||||||
|             return |             return | ||||||
|  |  | ||||||
| @@ -126,3 +118,42 @@ class FalyxCompleter(Completer): | |||||||
|         for key in keys: |         for key in keys: | ||||||
|             if key.upper().startswith(prefix): |             if key.upper().startswith(prefix): | ||||||
|                 yield Completion(key, start_position=-len(prefix)) |                 yield Completion(key, start_position=-len(prefix)) | ||||||
|  |  | ||||||
|  |     def _ensure_quote(self, text: str) -> str: | ||||||
|  |         """ | ||||||
|  |         Ensure the text is properly quoted for shell commands. | ||||||
|  |  | ||||||
|  |         Args: | ||||||
|  |             text (str): The input text to quote. | ||||||
|  |  | ||||||
|  |         Returns: | ||||||
|  |             str: The quoted text, suitable for shell command usage. | ||||||
|  |         """ | ||||||
|  |         if " " in text or "\t" in text: | ||||||
|  |             return f'"{text}"' | ||||||
|  |         return text | ||||||
|  |  | ||||||
|  |     def _yield_lcp_completions(self, suggestions, stub): | ||||||
|  |         matches = [s for s in suggestions if s.startswith(stub)] | ||||||
|  |         if not matches: | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         lcp = os.path.commonprefix(matches) | ||||||
|  |  | ||||||
|  |         if len(matches) == 1: | ||||||
|  |             yield Completion( | ||||||
|  |                 self._ensure_quote(matches[0]), | ||||||
|  |                 start_position=-len(stub), | ||||||
|  |                 display=matches[0], | ||||||
|  |             ) | ||||||
|  |         elif len(lcp) > len(stub) and not lcp.startswith("-"): | ||||||
|  |             yield Completion(lcp, start_position=-len(stub), display=lcp) | ||||||
|  |             for match in matches: | ||||||
|  |                 yield Completion( | ||||||
|  |                     self._ensure_quote(match), start_position=-len(stub), display=match | ||||||
|  |                 ) | ||||||
|  |         else: | ||||||
|  |             for match in matches: | ||||||
|  |                 yield Completion( | ||||||
|  |                     self._ensure_quote(match), start_position=-len(stub), display=match | ||||||
|  |                 ) | ||||||
|   | |||||||
							
								
								
									
										114
									
								
								falyx/falyx.py
									
									
									
									
									
								
							
							
						
						
									
										114
									
								
								falyx/falyx.py
									
									
									
									
									
								
							| @@ -37,6 +37,7 @@ from prompt_toolkit.formatted_text import StyleAndTextTuples | |||||||
| from prompt_toolkit.history import FileHistory | from prompt_toolkit.history import FileHistory | ||||||
| from prompt_toolkit.key_binding import KeyBindings | from prompt_toolkit.key_binding import KeyBindings | ||||||
| from prompt_toolkit.patch_stdout import patch_stdout | from prompt_toolkit.patch_stdout import patch_stdout | ||||||
|  | from prompt_toolkit.shortcuts import CompleteStyle | ||||||
| from prompt_toolkit.validation import ValidationError | from prompt_toolkit.validation import ValidationError | ||||||
| from rich import box | from rich import box | ||||||
| from rich.console import Console | from rich.console import Console | ||||||
| @@ -395,9 +396,12 @@ class Falyx: | |||||||
|                 return |                 return | ||||||
|             for command in commands: |             for command in commands: | ||||||
|                 usage, description, _ = command.help_signature |                 usage, description, _ = command.help_signature | ||||||
|                 self.console.print(usage) |                 self.console.print( | ||||||
|                 if description: |                     Padding( | ||||||
|                     self.console.print(description) |                         Panel(usage, expand=False, title=description, title_align="left"), | ||||||
|  |                         (0, 2), | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|             return |             return | ||||||
|  |  | ||||||
|         self.console.print("[bold]help:[/bold]") |         self.console.print("[bold]help:[/bold]") | ||||||
| @@ -559,6 +563,7 @@ class Falyx: | |||||||
|                 history=self.history, |                 history=self.history, | ||||||
|                 multiline=False, |                 multiline=False, | ||||||
|                 completer=self._get_completer(), |                 completer=self._get_completer(), | ||||||
|  |                 complete_style=CompleteStyle.COLUMN, | ||||||
|                 validator=CommandValidator(self, self._get_validator_error_message()), |                 validator=CommandValidator(self, self._get_validator_error_message()), | ||||||
|                 bottom_toolbar=self._get_bottom_bar_render(), |                 bottom_toolbar=self._get_bottom_bar_render(), | ||||||
|                 key_bindings=self.key_bindings, |                 key_bindings=self.key_bindings, | ||||||
| @@ -1155,7 +1160,59 @@ class Falyx: | |||||||
|         subparsers: _SubParsersAction | None = None, |         subparsers: _SubParsersAction | None = None, | ||||||
|         callback: Callable[..., Any] | None = None, |         callback: Callable[..., Any] | None = None, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         """Run Falyx CLI with structured subcommands.""" |         """ | ||||||
|  |         Entrypoint for executing a Falyx CLI application via structured subcommands. | ||||||
|  |  | ||||||
|  |         This method parses CLI arguments, configures the runtime environment, and dispatches | ||||||
|  |         execution to the appropriate command mode: | ||||||
|  |  | ||||||
|  |         - list - Show help output, optionally filtered by tag. | ||||||
|  |         - version - Print the program version and exit. | ||||||
|  |         - preview - Display a preview of the specified command without executing it. | ||||||
|  |         - run - Execute a single command with parsed arguments and lifecycle hooks. | ||||||
|  |         - run-all - Run all commands matching a tag concurrently (with default args). | ||||||
|  |         - (default) - Launch the interactive Falyx menu loop. | ||||||
|  |  | ||||||
|  |         It also applies CLI flags such as `--verbose`, `--debug-hooks`, and summary reporting, | ||||||
|  |         and supports an optional callback for post-parse setup. | ||||||
|  |  | ||||||
|  |         Args: | ||||||
|  |             falyx_parsers (FalyxParsers | None): | ||||||
|  |                 Preconfigured argument parser set. If not provided, a default parser | ||||||
|  |                 is created using the registered commands and passed-in `root_parser` | ||||||
|  |                 or `subparsers`. | ||||||
|  |             root_parser (ArgumentParser | None): | ||||||
|  |                 Optional root parser to merge into the CLI (used if `falyx_parsers` | ||||||
|  |                 is not supplied). | ||||||
|  |             subparsers (_SubParsersAction | None): | ||||||
|  |                 Optional subparser group to extend (used if `falyx_parsers` is not supplied). | ||||||
|  |             callback (Callable[..., Any] | None): | ||||||
|  |                 An optional function or coroutine to run after parsing CLI arguments, | ||||||
|  |                 typically for initializing logging, environment setup, or other | ||||||
|  |                 pre-execution configuration. | ||||||
|  |  | ||||||
|  |         Raises: | ||||||
|  |             FalyxError: | ||||||
|  |                 If invalid parser objects are supplied, or CLI arguments conflict | ||||||
|  |                 with the expected run mode. | ||||||
|  |             SystemExit: | ||||||
|  |                 Exits with an appropriate exit code based on the selected command | ||||||
|  |                 or signal (e.g. Ctrl+C triggers exit code 130). | ||||||
|  |  | ||||||
|  |         Notes: | ||||||
|  |             - `run-all` executes all tagged commands **in parallel** and does not | ||||||
|  |             supply arguments to individual commands; use `ChainedAction` or explicit | ||||||
|  |             CLI calls for ordered or parameterized workflows. | ||||||
|  |             - Most CLI commands exit the process via `sys.exit()` after completion. | ||||||
|  |             - For interactive sessions, this method falls back to `menu()`. | ||||||
|  |  | ||||||
|  |         Example: | ||||||
|  |             ``` | ||||||
|  |             >>> flx = Falyx() | ||||||
|  |             >>> await flx.run()   # Parses CLI args and dispatches appropriately | ||||||
|  |             ``` | ||||||
|  |         """ | ||||||
|  |  | ||||||
|         if self.cli_args: |         if self.cli_args: | ||||||
|             raise FalyxError( |             raise FalyxError( | ||||||
|                 "Run is incompatible with CLI arguments. Use 'run_key' instead." |                 "Run is incompatible with CLI arguments. Use 'run_key' instead." | ||||||
| @@ -1247,13 +1304,13 @@ class Falyx: | |||||||
|                 sys.exit(1) |                 sys.exit(1) | ||||||
|             except QuitSignal: |             except QuitSignal: | ||||||
|                 logger.info("[QuitSignal]. <- Exiting run.") |                 logger.info("[QuitSignal]. <- Exiting run.") | ||||||
|                 sys.exit(0) |                 sys.exit(130) | ||||||
|             except BackSignal: |             except BackSignal: | ||||||
|                 logger.info("[BackSignal]. <- Exiting run.") |                 logger.info("[BackSignal]. <- Exiting run.") | ||||||
|                 sys.exit(0) |                 sys.exit(1) | ||||||
|             except CancelSignal: |             except CancelSignal: | ||||||
|                 logger.info("[CancelSignal]. <- Exiting run.") |                 logger.info("[CancelSignal]. <- Exiting run.") | ||||||
|                 sys.exit(0) |                 sys.exit(1) | ||||||
|  |  | ||||||
|             if self.cli_args.summary: |             if self.cli_args.summary: | ||||||
|                 er.summary() |                 er.summary() | ||||||
| @@ -1278,22 +1335,35 @@ class Falyx: | |||||||
|                 f"{self.cli_args.tag}" |                 f"{self.cli_args.tag}" | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|             for cmd in matching: |             tasks = [] | ||||||
|                 self._set_retry_policy(cmd) |             try: | ||||||
|                 try: |                 for cmd in matching: | ||||||
|                     await self.run_key(cmd.key) |                     self._set_retry_policy(cmd) | ||||||
|                 except FalyxError as error: |                     tasks.append(self.run_key(cmd.key)) | ||||||
|                     self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]") |             except Exception as error: | ||||||
|                     sys.exit(1) |                 self.console.print( | ||||||
|                 except QuitSignal: |                     f"[{OneColors.DARK_RED}]❌ Unexpected error: {error}[/]" | ||||||
|  |                 ) | ||||||
|  |                 sys.exit(1) | ||||||
|  |  | ||||||
|  |             had_errors = False | ||||||
|  |             results = await asyncio.gather(*tasks, return_exceptions=True) | ||||||
|  |             for result in results: | ||||||
|  |                 if isinstance(result, QuitSignal): | ||||||
|                     logger.info("[QuitSignal]. <- Exiting run.") |                     logger.info("[QuitSignal]. <- Exiting run.") | ||||||
|                     sys.exit(0) |                     sys.exit(130) | ||||||
|                 except BackSignal: |                 elif isinstance(result, CancelSignal): | ||||||
|                     logger.info("[BackSignal]. <- Exiting run.") |                     logger.info("[CancelSignal]. <- Execution cancelled.") | ||||||
|                     sys.exit(0) |                     sys.exit(1) | ||||||
|                 except CancelSignal: |                 elif isinstance(result, BackSignal): | ||||||
|                     logger.info("[CancelSignal]. <- Exiting run.") |                     logger.info("[BackSignal]. <- Back signal received.") | ||||||
|                     sys.exit(0) |                     sys.exit(1) | ||||||
|  |                 elif isinstance(result, FalyxError): | ||||||
|  |                     self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {result}[/]") | ||||||
|  |                     had_errors = True | ||||||
|  |  | ||||||
|  |             if had_errors: | ||||||
|  |                 sys.exit(1) | ||||||
|  |  | ||||||
|             if self.cli_args.summary: |             if self.cli_args.summary: | ||||||
|                 er.summary() |                 er.summary() | ||||||
|   | |||||||
| @@ -49,6 +49,7 @@ from __future__ import annotations | |||||||
|  |  | ||||||
| from collections import Counter, defaultdict | from collections import Counter, defaultdict | ||||||
| from copy import deepcopy | from copy import deepcopy | ||||||
|  | from pathlib import Path | ||||||
| from typing import Any, Iterable, Sequence | from typing import Any, Iterable, Sequence | ||||||
|  |  | ||||||
| from rich.console import Console | from rich.console import Console | ||||||
| @@ -1101,6 +1102,24 @@ class CommandArgumentParser: | |||||||
|                 kwargs_dict[arg.dest] = parsed[arg.dest] |                 kwargs_dict[arg.dest] = parsed[arg.dest] | ||||||
|         return tuple(args_list), kwargs_dict |         return tuple(args_list), kwargs_dict | ||||||
|  |  | ||||||
|  |     def _suggest_paths(self, stub: str) -> list[str]: | ||||||
|  |         """Return filesystem path suggestions based on a stub.""" | ||||||
|  |         path = Path(stub or ".").expanduser() | ||||||
|  |         base_dir = path if path.is_dir() else path.parent | ||||||
|  |         if not base_dir.exists(): | ||||||
|  |             return [] | ||||||
|  |         completions = [] | ||||||
|  |         for child in base_dir.iterdir(): | ||||||
|  |             name = str(child) | ||||||
|  |             if child.is_dir(): | ||||||
|  |                 name += "/" | ||||||
|  |             completions.append(name) | ||||||
|  |         if stub and not path.is_dir(): | ||||||
|  |             completions = [ | ||||||
|  |                 completion for completion in completions if completion.startswith(stub) | ||||||
|  |             ] | ||||||
|  |         return completions[:100] | ||||||
|  |  | ||||||
|     def suggest_next( |     def suggest_next( | ||||||
|         self, args: list[str], cursor_at_end_of_token: bool = False |         self, args: list[str], cursor_at_end_of_token: bool = False | ||||||
|     ) -> list[str]: |     ) -> list[str]: | ||||||
| @@ -1117,6 +1136,7 @@ class CommandArgumentParser: | |||||||
|             list[str]: List of suggested completions. |             list[str]: List of suggested completions. | ||||||
|         """ |         """ | ||||||
|  |  | ||||||
|  |         last = args[-1] if args else "" | ||||||
|         # Case 1: Next positional argument |         # Case 1: Next positional argument | ||||||
|         next_non_consumed_positional: Argument | None = None |         next_non_consumed_positional: Argument | None = None | ||||||
|         for state in self._last_positional_states.values(): |         for state in self._last_positional_states.values(): | ||||||
| @@ -1126,11 +1146,33 @@ class CommandArgumentParser: | |||||||
|  |  | ||||||
|         if next_non_consumed_positional: |         if next_non_consumed_positional: | ||||||
|             if next_non_consumed_positional.choices: |             if next_non_consumed_positional.choices: | ||||||
|  |                 if ( | ||||||
|  |                     cursor_at_end_of_token | ||||||
|  |                     and last | ||||||
|  |                     and any( | ||||||
|  |                         str(choice).startswith(last) | ||||||
|  |                         for choice in next_non_consumed_positional.choices | ||||||
|  |                     ) | ||||||
|  |                     and next_non_consumed_positional.nargs in (1, "?", None) | ||||||
|  |                 ): | ||||||
|  |                     return [] | ||||||
|                 return sorted( |                 return sorted( | ||||||
|                     (str(choice) for choice in next_non_consumed_positional.choices) |                     (str(choice) for choice in next_non_consumed_positional.choices) | ||||||
|                 ) |                 ) | ||||||
|             if next_non_consumed_positional.suggestions: |             if next_non_consumed_positional.suggestions: | ||||||
|  |                 if ( | ||||||
|  |                     cursor_at_end_of_token | ||||||
|  |                     and last | ||||||
|  |                     and any( | ||||||
|  |                         str(suggestion).startswith(last) | ||||||
|  |                         for suggestion in next_non_consumed_positional.suggestions | ||||||
|  |                     ) | ||||||
|  |                     and next_non_consumed_positional.nargs in (1, "?", None) | ||||||
|  |                 ): | ||||||
|  |                     return [] | ||||||
|                 return sorted(next_non_consumed_positional.suggestions) |                 return sorted(next_non_consumed_positional.suggestions) | ||||||
|  |             if next_non_consumed_positional.type == Path: | ||||||
|  |                 return self._suggest_paths(args[-1] if args else "") | ||||||
|  |  | ||||||
|         consumed_dests = [ |         consumed_dests = [ | ||||||
|             state.arg.dest |             state.arg.dest | ||||||
| @@ -1163,12 +1205,13 @@ class CommandArgumentParser: | |||||||
|                 and next_to_last in self._keyword |                 and next_to_last in self._keyword | ||||||
|                 and next_to_last in remaining_flags |                 and next_to_last in remaining_flags | ||||||
|             ): |             ): | ||||||
|                 # If the last token is a mid-flag, suggest based on the previous flag |  | ||||||
|                 arg = self._keyword[next_to_last] |                 arg = self._keyword[next_to_last] | ||||||
|                 if arg.choices: |                 if arg.choices: | ||||||
|                     suggestions.extend(arg.choices) |                     suggestions.extend((str(choice) for choice in arg.choices)) | ||||||
|                 elif arg.suggestions: |                 elif arg.suggestions: | ||||||
|                     suggestions.extend(arg.suggestions) |                     suggestions.extend( | ||||||
|  |                         (str(suggestion) for suggestion in arg.suggestions) | ||||||
|  |                     ) | ||||||
|             else: |             else: | ||||||
|                 possible_flags = [ |                 possible_flags = [ | ||||||
|                     flag |                     flag | ||||||
| @@ -1185,9 +1228,11 @@ class CommandArgumentParser: | |||||||
|             ): |             ): | ||||||
|                 pass |                 pass | ||||||
|             elif arg.choices: |             elif arg.choices: | ||||||
|                 suggestions.extend(arg.choices) |                 suggestions.extend((str(choice) for choice in arg.choices)) | ||||||
|             elif arg.suggestions: |             elif arg.suggestions: | ||||||
|                 suggestions.extend(arg.suggestions) |                 suggestions.extend((str(suggestion) for suggestion in arg.suggestions)) | ||||||
|  |             elif arg.type == Path: | ||||||
|  |                 suggestions.extend(self._suggest_paths(".")) | ||||||
|         # Case 4: Last flag with choices mid-choice (e.g., ["--tag", "v"]) |         # Case 4: Last flag with choices mid-choice (e.g., ["--tag", "v"]) | ||||||
|         elif next_to_last in self._keyword: |         elif next_to_last in self._keyword: | ||||||
|             arg = self._keyword[next_to_last] |             arg = self._keyword[next_to_last] | ||||||
| @@ -1204,7 +1249,7 @@ class CommandArgumentParser: | |||||||
|             ): |             ): | ||||||
|                 pass |                 pass | ||||||
|             elif arg.choices and last not in arg.choices and not cursor_at_end_of_token: |             elif arg.choices and last not in arg.choices and not cursor_at_end_of_token: | ||||||
|                 suggestions.extend(arg.choices) |                 suggestions.extend((str(choice) for choice in arg.choices)) | ||||||
|             elif ( |             elif ( | ||||||
|                 arg.suggestions |                 arg.suggestions | ||||||
|                 and last not in arg.suggestions |                 and last not in arg.suggestions | ||||||
| @@ -1212,9 +1257,11 @@ class CommandArgumentParser: | |||||||
|                 and any(suggestion.startswith(last) for suggestion in arg.suggestions) |                 and any(suggestion.startswith(last) for suggestion in arg.suggestions) | ||||||
|                 and not cursor_at_end_of_token |                 and not cursor_at_end_of_token | ||||||
|             ): |             ): | ||||||
|                 suggestions.extend(arg.suggestions) |                 suggestions.extend((str(suggestion) for suggestion in arg.suggestions)) | ||||||
|             elif last_keyword_state_in_args and not last_keyword_state_in_args.consumed: |             elif last_keyword_state_in_args and not last_keyword_state_in_args.consumed: | ||||||
|                 pass |                 pass | ||||||
|  |             elif arg.type == Path and not cursor_at_end_of_token: | ||||||
|  |                 suggestions.extend(self._suggest_paths(last)) | ||||||
|             else: |             else: | ||||||
|                 suggestions.extend(remaining_flags) |                 suggestions.extend(remaining_flags) | ||||||
|         elif last_keyword_state_in_args and not last_keyword_state_in_args.consumed: |         elif last_keyword_state_in_args and not last_keyword_state_in_args.consumed: | ||||||
|   | |||||||
| @@ -118,6 +118,10 @@ def words_validator( | |||||||
|  |  | ||||||
| def word_validator(word: str) -> Validator: | def word_validator(word: str) -> Validator: | ||||||
|     """Validator for specific word inputs.""" |     """Validator for specific word inputs.""" | ||||||
|  |     if word.upper() == "N": | ||||||
|  |         raise ValueError( | ||||||
|  |             "The word 'N' is reserved for yes/no validation. Use yes_no_validator instead." | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def validate(text: str) -> bool: |     def validate(text: str) -> bool: | ||||||
|         if text.upper().strip() == "N": |         if text.upper().strip() == "N": | ||||||
|   | |||||||
| @@ -1 +1 @@ | |||||||
| __version__ = "0.1.78" | __version__ = "0.1.80" | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| [tool.poetry] | [tool.poetry] | ||||||
| name = "falyx" | name = "falyx" | ||||||
| version = "0.1.78" | version = "0.1.80" | ||||||
| description = "Reliable and introspectable async CLI action framework." | description = "Reliable and introspectable async CLI action framework." | ||||||
| authors = ["Roland Thomas Jr <roland@rtj.dev>"] | authors = ["Roland Thomas Jr <roland@rtj.dev>"] | ||||||
| license = "MIT" | license = "MIT" | ||||||
|   | |||||||
							
								
								
									
										97
									
								
								tests/test_completer/test_completer.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								tests/test_completer/test_completer.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | |||||||
|  | from types import SimpleNamespace | ||||||
|  |  | ||||||
|  | import pytest | ||||||
|  | from prompt_toolkit.completion import Completion | ||||||
|  | from prompt_toolkit.document import Document | ||||||
|  |  | ||||||
|  | from falyx.completer import FalyxCompleter | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | def fake_falyx(): | ||||||
|  |     fake_arg_parser = SimpleNamespace( | ||||||
|  |         suggest_next=lambda tokens, end: ["--tag", "--name", "value with space"] | ||||||
|  |     ) | ||||||
|  |     fake_command = SimpleNamespace(key="R", aliases=["RUN"], arg_parser=fake_arg_parser) | ||||||
|  |     return SimpleNamespace( | ||||||
|  |         exit_command=SimpleNamespace(key="X", aliases=["EXIT"]), | ||||||
|  |         help_command=SimpleNamespace(key="H", aliases=["HELP"]), | ||||||
|  |         history_command=SimpleNamespace(key="Y", aliases=["HISTORY"]), | ||||||
|  |         commands={"R": fake_command}, | ||||||
|  |         _name_map={"R": fake_command, "RUN": fake_command, "X": fake_command}, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_suggest_commands(fake_falyx): | ||||||
|  |     completer = FalyxCompleter(fake_falyx) | ||||||
|  |     completions = list(completer._suggest_commands("R")) | ||||||
|  |     assert any(c.text == "R" for c in completions) | ||||||
|  |     assert any(c.text == "RUN" for c in completions) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_suggest_commands_empty(fake_falyx): | ||||||
|  |     completer = FalyxCompleter(fake_falyx) | ||||||
|  |     completions = list(completer._suggest_commands("")) | ||||||
|  |     assert any(c.text == "X" for c in completions) | ||||||
|  |     assert any(c.text == "H" for c in completions) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_suggest_commands_no_match(fake_falyx): | ||||||
|  |     completer = FalyxCompleter(fake_falyx) | ||||||
|  |     completions = list(completer._suggest_commands("Z")) | ||||||
|  |     assert not completions | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_get_completions_no_input(fake_falyx): | ||||||
|  |     completer = FalyxCompleter(fake_falyx) | ||||||
|  |     doc = Document("") | ||||||
|  |     results = list(completer.get_completions(doc, None)) | ||||||
|  |     assert any(isinstance(c, Completion) for c in results) | ||||||
|  |     assert any(c.text == "X" for c in results) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_get_completions_no_match(fake_falyx): | ||||||
|  |     completer = FalyxCompleter(fake_falyx) | ||||||
|  |     doc = Document("Z") | ||||||
|  |     completions = list(completer.get_completions(doc, None)) | ||||||
|  |     assert not completions | ||||||
|  |     doc = Document("Z Z") | ||||||
|  |     completions = list(completer.get_completions(doc, None)) | ||||||
|  |     assert not completions | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_get_completions_partial_command(fake_falyx): | ||||||
|  |     completer = FalyxCompleter(fake_falyx) | ||||||
|  |     doc = Document("R") | ||||||
|  |     results = list(completer.get_completions(doc, None)) | ||||||
|  |     assert any(c.text in ("R", "RUN") for c in results) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_get_completions_with_flag(fake_falyx): | ||||||
|  |     completer = FalyxCompleter(fake_falyx) | ||||||
|  |     doc = Document("R ") | ||||||
|  |     results = list(completer.get_completions(doc, None)) | ||||||
|  |     assert "--tag" in [c.text for c in results] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_get_completions_partial_flag(fake_falyx): | ||||||
|  |     completer = FalyxCompleter(fake_falyx) | ||||||
|  |     doc = Document("R --t") | ||||||
|  |     results = list(completer.get_completions(doc, None)) | ||||||
|  |     assert all(c.start_position <= 0 for c in results) | ||||||
|  |     assert any(c.text.startswith("--t") or c.display == "--tag" for c in results) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_get_completions_bad_input(fake_falyx): | ||||||
|  |     completer = FalyxCompleter(fake_falyx) | ||||||
|  |     doc = Document('R "unclosed quote') | ||||||
|  |     results = list(completer.get_completions(doc, None)) | ||||||
|  |     assert results == [] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_get_completions_exception_handling(fake_falyx): | ||||||
|  |     completer = FalyxCompleter(fake_falyx) | ||||||
|  |     fake_falyx.commands["R"].arg_parser.suggest_next = lambda *args: 1 / 0 | ||||||
|  |     doc = Document("R --tag") | ||||||
|  |     results = list(completer.get_completions(doc, None)) | ||||||
|  |     assert results == [] | ||||||
							
								
								
									
										38
									
								
								tests/test_completer/test_lcp_completions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								tests/test_completer/test_lcp_completions.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | |||||||
|  | from types import SimpleNamespace | ||||||
|  |  | ||||||
|  | import pytest | ||||||
|  | from prompt_toolkit.document import Document | ||||||
|  |  | ||||||
|  | from falyx.completer import FalyxCompleter | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | def fake_falyx(): | ||||||
|  |     fake_arg_parser = SimpleNamespace( | ||||||
|  |         suggest_next=lambda tokens, end: ["AETHERWARP", "AETHERZOOM"] | ||||||
|  |     ) | ||||||
|  |     fake_command = SimpleNamespace(key="R", aliases=["RUN"], arg_parser=fake_arg_parser) | ||||||
|  |     return SimpleNamespace( | ||||||
|  |         exit_command=SimpleNamespace(key="X", aliases=["EXIT"]), | ||||||
|  |         help_command=SimpleNamespace(key="H", aliases=["HELP"]), | ||||||
|  |         history_command=SimpleNamespace(key="Y", aliases=["HISTORY"]), | ||||||
|  |         commands={"R": fake_command}, | ||||||
|  |         _name_map={"R": fake_command, "RUN": fake_command, "X": fake_command}, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_lcp_completions(fake_falyx): | ||||||
|  |     completer = FalyxCompleter(fake_falyx) | ||||||
|  |     doc = Document("R A") | ||||||
|  |     results = list(completer.get_completions(doc, None)) | ||||||
|  |     assert any(c.text == "AETHER" for c in results) | ||||||
|  |     assert any(c.text == "AETHERWARP" for c in results) | ||||||
|  |     assert any(c.text == "AETHERZOOM" for c in results) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_lcp_completions_space(fake_falyx): | ||||||
|  |     completer = FalyxCompleter(fake_falyx) | ||||||
|  |     suggestions = ["London", "New York", "San Francisco"] | ||||||
|  |     stub = "N" | ||||||
|  |     completions = list(completer._yield_lcp_completions(suggestions, stub)) | ||||||
|  |     assert any(c.text == '"New York"' for c in completions) | ||||||
							
								
								
									
										42
									
								
								tests/test_validators/test_command_validator.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								tests/test_validators/test_command_validator.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | |||||||
|  | from unittest.mock import AsyncMock | ||||||
|  |  | ||||||
|  | import pytest | ||||||
|  | from prompt_toolkit.document import Document | ||||||
|  | from prompt_toolkit.validation import ValidationError | ||||||
|  |  | ||||||
|  | from falyx.validators import CommandValidator | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_command_validator_validates_command(): | ||||||
|  |     fake_falyx = AsyncMock() | ||||||
|  |     fake_falyx.get_command.return_value = (False, object(), (), {}) | ||||||
|  |     validator = CommandValidator(fake_falyx, "Invalid!") | ||||||
|  |  | ||||||
|  |     await validator.validate_async(Document("valid")) | ||||||
|  |     fake_falyx.get_command.assert_awaited_once() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_command_validator_rejects_invalid_command(): | ||||||
|  |     fake_falyx = AsyncMock() | ||||||
|  |     fake_falyx.get_command.return_value = (False, None, (), {}) | ||||||
|  |     validator = CommandValidator(fake_falyx, "Invalid!") | ||||||
|  |  | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         await validator.validate_async(Document("not_a_command")) | ||||||
|  |  | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         await validator.validate_async(Document("")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_command_validator_is_preview(): | ||||||
|  |     fake_falyx = AsyncMock() | ||||||
|  |     fake_falyx.get_command.return_value = (True, None, (), {}) | ||||||
|  |     validator = CommandValidator(fake_falyx, "Invalid!") | ||||||
|  |  | ||||||
|  |     await validator.validate_async(Document("?preview_command")) | ||||||
|  |     fake_falyx.get_command.assert_awaited_once_with( | ||||||
|  |         "?preview_command", from_validate=True | ||||||
|  |     ) | ||||||
							
								
								
									
										24
									
								
								tests/test_validators/test_int_range_validator.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								tests/test_validators/test_int_range_validator.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | |||||||
|  | import pytest | ||||||
|  | from prompt_toolkit.document import Document | ||||||
|  | from prompt_toolkit.validation import ValidationError | ||||||
|  |  | ||||||
|  | from falyx.validators import int_range_validator | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_int_range_validator_accepts_valid_numbers(): | ||||||
|  |     validator = int_range_validator(1, 10) | ||||||
|  |     for valid in ["1", "5", "10"]: | ||||||
|  |         validator.validate(Document(valid)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.parametrize("invalid", ["0", "11", "5.5", "hello", "-1", ""]) | ||||||
|  | def test_int_range_validator_rejects_invalid(invalid): | ||||||
|  |     validator = int_range_validator(1, 10) | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         validator.validate(Document(invalid)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_int_range_validator_edge_cases(): | ||||||
|  |     validator = int_range_validator(1, 10) | ||||||
|  |     for valid in ["1", "10"]: | ||||||
|  |         validator.validate(Document(valid)) | ||||||
							
								
								
									
										18
									
								
								tests/test_validators/test_key_validator.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								tests/test_validators/test_key_validator.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | import pytest | ||||||
|  | from prompt_toolkit.document import Document | ||||||
|  | from prompt_toolkit.validation import ValidationError | ||||||
|  |  | ||||||
|  | from falyx.validators import key_validator | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_key_validator_accepts_valid_keys(): | ||||||
|  |     validator = key_validator(["A", "B", "Z"]) | ||||||
|  |     for valid in ["A", "B", "Z"]: | ||||||
|  |         validator.validate(Document(valid)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.parametrize("invalid", ["Y", "D", "C", "", "1", "AB", "ZB"]) | ||||||
|  | def test_key_validator_rejects_invalid(invalid): | ||||||
|  |     validator = key_validator(["A", "B", "Z"]) | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         validator.validate(Document(invalid)) | ||||||
							
								
								
									
										73
									
								
								tests/test_validators/test_multi_index_validator.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								tests/test_validators/test_multi_index_validator.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | |||||||
|  | import pytest | ||||||
|  | from prompt_toolkit.document import Document | ||||||
|  | from prompt_toolkit.validation import ValidationError | ||||||
|  |  | ||||||
|  | from falyx.validators import MultiIndexValidator | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_multi_index_validator_accepts_valid_indices(): | ||||||
|  |     validator = MultiIndexValidator( | ||||||
|  |         1, 5, number_selections=3, separator=",", allow_duplicates=False, cancel_key="C" | ||||||
|  |     ) | ||||||
|  |     for valid in ["1,2,3", "2,3,4", "1,4,5"]: | ||||||
|  |         validator.validate(Document(valid)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_multi_index_validator_rejects_invalid_indices(): | ||||||
|  |     validator = MultiIndexValidator( | ||||||
|  |         1, 5, number_selections=3, separator=",", allow_duplicates=False, cancel_key="C" | ||||||
|  |     ) | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         validator.validate(Document("A,!,F")) | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         validator.validate(Document("0,6,7")) | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         validator.validate(Document("1,2,2")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_multi_index_validator_rejects_invalid_number_of_selections(): | ||||||
|  |     validator = MultiIndexValidator( | ||||||
|  |         1, 5, number_selections=3, separator=",", allow_duplicates=False, cancel_key="C" | ||||||
|  |     ) | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         validator.validate(Document("1,2")) | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         validator.validate(Document("1,2,3,4")) | ||||||
|  |     validator = MultiIndexValidator( | ||||||
|  |         1, 5, number_selections=1, separator=",", allow_duplicates=False, cancel_key="C" | ||||||
|  |     ) | ||||||
|  |     validator.validate(Document("1")) | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         validator.validate(Document("2,3")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_multi_index_validator_cancel_key(): | ||||||
|  |     validator = MultiIndexValidator( | ||||||
|  |         1, 5, number_selections=3, separator=",", allow_duplicates=False, cancel_key="C" | ||||||
|  |     ) | ||||||
|  |     validator.validate(Document("C")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_multi_index_validator_cancel_alone(): | ||||||
|  |     validator = MultiIndexValidator( | ||||||
|  |         1, 5, number_selections=3, separator=",", allow_duplicates=False, cancel_key="C" | ||||||
|  |     ) | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         validator.validate(Document("1,C")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_multi_index_validator_empty_input(): | ||||||
|  |     validator = MultiIndexValidator( | ||||||
|  |         1, 5, number_selections=3, separator=",", allow_duplicates=False, cancel_key="C" | ||||||
|  |     ) | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         validator.validate(Document("")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_multi_index_validator_error_message_for_duplicates(): | ||||||
|  |     validator = MultiIndexValidator( | ||||||
|  |         1, 5, number_selections=3, separator=",", allow_duplicates=False, cancel_key="C" | ||||||
|  |     ) | ||||||
|  |     with pytest.raises(ValidationError) as e: | ||||||
|  |         validator.validate(Document("1,1,2")) | ||||||
|  |     assert "Duplicate selection" in str(e.value) | ||||||
							
								
								
									
										105
									
								
								tests/test_validators/test_multi_key_validator.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								tests/test_validators/test_multi_key_validator.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,105 @@ | |||||||
|  | import pytest | ||||||
|  | from prompt_toolkit.document import Document | ||||||
|  | from prompt_toolkit.validation import ValidationError | ||||||
|  |  | ||||||
|  | from falyx.validators import MultiKeyValidator | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_multi_key_validator_accepts_valid_keys(): | ||||||
|  |     validator = MultiKeyValidator( | ||||||
|  |         ["A", "B", "C"], | ||||||
|  |         number_selections=2, | ||||||
|  |         separator=",", | ||||||
|  |         allow_duplicates=False, | ||||||
|  |         cancel_key="X", | ||||||
|  |     ) | ||||||
|  |     for valid in ["A,B", "B,C", "A,C"]: | ||||||
|  |         validator.validate(Document(valid)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_multi_key_validator_rejects_invalid_keys(): | ||||||
|  |     validator = MultiKeyValidator( | ||||||
|  |         ["A", "B", "C"], | ||||||
|  |         number_selections=2, | ||||||
|  |         separator=",", | ||||||
|  |         allow_duplicates=False, | ||||||
|  |         cancel_key="X", | ||||||
|  |     ) | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         validator.validate(Document("D,E,F")) | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         validator.validate(Document("A,B,A")) | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         validator.validate(Document("A,B,C,D")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_multi_key_validator_rejects_invalid_number_of_selections(): | ||||||
|  |     validator = MultiKeyValidator( | ||||||
|  |         ["A", "B", "C"], | ||||||
|  |         number_selections=2, | ||||||
|  |         separator=",", | ||||||
|  |         allow_duplicates=False, | ||||||
|  |         cancel_key="X", | ||||||
|  |     ) | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         validator.validate(Document("A"))  # Not enough selections | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         validator.validate(Document("A,B,C"))  # Too many selections | ||||||
|  |     validator = MultiKeyValidator( | ||||||
|  |         ["A", "B", "C"], | ||||||
|  |         number_selections=1, | ||||||
|  |         separator=",", | ||||||
|  |         allow_duplicates=False, | ||||||
|  |         cancel_key="X", | ||||||
|  |     ) | ||||||
|  |     validator.validate(Document("A"))  # Exactly one selection is valid | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         validator.validate(Document("B,C"))  # Too many selections | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_multi_key_validator_cancel_key(): | ||||||
|  |     validator = MultiKeyValidator( | ||||||
|  |         ["A", "B", "C"], | ||||||
|  |         number_selections=2, | ||||||
|  |         separator=",", | ||||||
|  |         allow_duplicates=False, | ||||||
|  |         cancel_key="X", | ||||||
|  |     ) | ||||||
|  |     validator.validate(Document("X")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_multi_key_validator_cancel_alone(): | ||||||
|  |     validator = MultiKeyValidator( | ||||||
|  |         ["A", "B", "C"], | ||||||
|  |         number_selections=2, | ||||||
|  |         separator=",", | ||||||
|  |         allow_duplicates=False, | ||||||
|  |         cancel_key="X", | ||||||
|  |     ) | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         validator.validate(Document("A,X")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_multi_key_validator_empty_input(): | ||||||
|  |     validator = MultiKeyValidator( | ||||||
|  |         ["A", "B", "C"], | ||||||
|  |         number_selections=2, | ||||||
|  |         separator=",", | ||||||
|  |         allow_duplicates=False, | ||||||
|  |         cancel_key="X", | ||||||
|  |     ) | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         validator.validate(Document("")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_multi_key_validator_error_message_for_duplicates(): | ||||||
|  |     validator = MultiKeyValidator( | ||||||
|  |         ["A", "B", "C"], | ||||||
|  |         number_selections=2, | ||||||
|  |         separator=",", | ||||||
|  |         allow_duplicates=False, | ||||||
|  |         cancel_key="X", | ||||||
|  |     ) | ||||||
|  |     with pytest.raises(ValidationError) as e: | ||||||
|  |         validator.validate(Document("A,A,B")) | ||||||
|  |     assert "Duplicate selection" in str(e.value) | ||||||
							
								
								
									
										29
									
								
								tests/test_validators/test_word_validator.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								tests/test_validators/test_word_validator.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | |||||||
|  | import pytest | ||||||
|  | from prompt_toolkit.document import Document | ||||||
|  | from prompt_toolkit.validation import ValidationError | ||||||
|  |  | ||||||
|  | from falyx.validators import word_validator | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_word_validator_accepts_valid_words(): | ||||||
|  |     validator = word_validator("apple") | ||||||
|  |     validator.validate(Document("apple")) | ||||||
|  |     validator.validate(Document("N")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_word_validator_accepts_case_insensitive(): | ||||||
|  |     validator = word_validator("banana") | ||||||
|  |     validator.validate(Document("BANANA")) | ||||||
|  |     validator.validate(Document("banana")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_word_validator_rejects_n(): | ||||||
|  |     with pytest.raises(ValueError): | ||||||
|  |         word_validator("N") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.parametrize("invalid", ["yes", "no", "maybe", "", "1"]) | ||||||
|  | def test_word_validator_rejects_invalid(invalid): | ||||||
|  |     validator = word_validator("apple") | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         validator.validate(Document(invalid)) | ||||||
							
								
								
									
										18
									
								
								tests/test_validators/test_words_validator.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								tests/test_validators/test_words_validator.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | import pytest | ||||||
|  | from prompt_toolkit.document import Document | ||||||
|  | from prompt_toolkit.validation import ValidationError | ||||||
|  |  | ||||||
|  | from falyx.validators import words_validator | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_words_validator_accepts_valid_words(): | ||||||
|  |     validator = words_validator(["hello", "world", "falyx"]) | ||||||
|  |     for valid in ["hello", "world", "falyx"]: | ||||||
|  |         validator.validate(Document(valid))  # should not raise | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.parametrize("invalid", ["yes", "no", "maybe", "", "1"]) | ||||||
|  | def test_words_validator_rejects_invalid(invalid): | ||||||
|  |     validator = words_validator(["hello", "world", "falyx"]) | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         validator.validate(Document(invalid)) | ||||||
							
								
								
									
										18
									
								
								tests/test_validators/test_yes_no_validator.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								tests/test_validators/test_yes_no_validator.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | import pytest | ||||||
|  | from prompt_toolkit.document import Document | ||||||
|  | from prompt_toolkit.validation import ValidationError | ||||||
|  |  | ||||||
|  | from falyx.validators import yes_no_validator | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_yes_no_validator_accepts_y_and_n(): | ||||||
|  |     validator = yes_no_validator() | ||||||
|  |     for valid in ["Y", "y", "N", "n"]: | ||||||
|  |         validator.validate(Document(valid)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.parametrize("invalid", ["yes", "no", "maybe", "", "1"]) | ||||||
|  | def test_yes_no_validator_rejects_invalid(invalid): | ||||||
|  |     validator = yes_no_validator() | ||||||
|  |     with pytest.raises(ValidationError): | ||||||
|  |         validator.validate(Document(invalid)) | ||||||
		Reference in New Issue
	
	Block a user