Compare commits
	
		
			7 Commits
		
	
	
		
			3b2c33d28f
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 1ce1b2385b | |||
| 06bf0e432c | |||
| 169f228c92 | |||
| 0417a06ee4 | |||
| 55d581b870 | |||
| a25888f316 | |||
| 8e306b9eaf | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -15,3 +15,4 @@ build/ | |||||||
| .vscode/ | .vscode/ | ||||||
| coverage.xml | coverage.xml | ||||||
| .coverage | .coverage | ||||||
|  | .config.json | ||||||
|   | |||||||
| @@ -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,27 @@ 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, |     numbers: list[int] | None = None, | ||||||
|  |     just_a_bool: bool = False, | ||||||
| ) -> str: | ) -> str: | ||||||
|  |     if numbers is None: | ||||||
|  |         numbers = [] | ||||||
|     if verbose: |     if verbose: | ||||||
|         print(f"Deploying {service}:{tag}:{number} to {region} at {place}...") |         print( | ||||||
|     return f"{service}:{tag}:{number} deployed to {region} at {place}" |             f"Deploying {service}:{tag}:{"|".join(str(number) for number in numbers)} to {region} at {place} from {path}..." | ||||||
|  |         ) | ||||||
|  |     return f"{service}:{tag}:{"|".join(str(number) for number in numbers)} deployed to {region} at {place} from {path}." | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_path_arg(*paths: Path) -> str: | ||||||
|  |     return f"Path argument received: {'|'.join(str(path) for path in paths)}" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_positional_numbers(*numbers: int) -> str: | ||||||
|  |     return f"Positional numbers received: {', '.join(str(num) for num in numbers)}" | ||||||
|  |  | ||||||
|  |  | ||||||
| def default_config(parser: CommandArgumentParser) -> None: | def default_config(parser: CommandArgumentParser) -> None: | ||||||
| @@ -52,22 +67,37 @@ 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( | ||||||
|  |         "-p", | ||||||
|  |         "--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", | ||||||
|         help="Enable verbose output.", |         help="Enable verbose output.", | ||||||
|     ) |     ) | ||||||
|     parser.add_argument( |     parser.add_argument( | ||||||
|  |         "-t", | ||||||
|         "--tag", |         "--tag", | ||||||
|         type=str, |         type=str, | ||||||
|         help="Optional tag for the deployment.", |         help="Optional tag for the deployment.", | ||||||
|         suggestions=["latest", "stable", "beta"], |         suggestions=["latest", "stable", "beta"], | ||||||
|     ) |     ) | ||||||
|     parser.add_argument( |     parser.add_argument( | ||||||
|         "--number", |         "--numbers", | ||||||
|         type=int, |         type=int, | ||||||
|  |         nargs="*", | ||||||
|  |         default=[1, 2, 3], | ||||||
|         help="Optional number argument.", |         help="Optional number argument.", | ||||||
|     ) |     ) | ||||||
|  |     parser.add_argument( | ||||||
|  |         "-j", | ||||||
|  |         "--just-a-bool", | ||||||
|  |         action="store_true", | ||||||
|  |         help="Just a boolean flag.", | ||||||
|  |     ) | ||||||
|     parser.add_tldr_examples( |     parser.add_tldr_examples( | ||||||
|         [ |         [ | ||||||
|             ("web", "Deploy 'web' to the default location (New York)"), |             ("web", "Deploy 'web' to the default location (New York)"), | ||||||
| @@ -77,6 +107,40 @@ def default_config(parser: CommandArgumentParser) -> None: | |||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def path_config(parser: CommandArgumentParser) -> None: | ||||||
|  |     """Argument configuration for path testing command.""" | ||||||
|  |     parser.add_argument( | ||||||
|  |         "paths", | ||||||
|  |         type=Path, | ||||||
|  |         nargs="*", | ||||||
|  |         help="One or more file or directory paths.", | ||||||
|  |     ) | ||||||
|  |     parser.add_tldr_examples( | ||||||
|  |         [ | ||||||
|  |             ("/path/to/file.txt", "Single file path"), | ||||||
|  |             ("/path/to/dir1 /path/to/dir2", "Multiple directory paths"), | ||||||
|  |             ("/path/with spaces/file.txt", "Path with spaces"), | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def numbers_config(parser: CommandArgumentParser) -> None: | ||||||
|  |     """Argument configuration for positional numbers testing command.""" | ||||||
|  |     parser.add_argument( | ||||||
|  |         "numbers", | ||||||
|  |         type=int, | ||||||
|  |         nargs="*", | ||||||
|  |         help="One or more integers.", | ||||||
|  |     ) | ||||||
|  |     parser.add_tldr_examples( | ||||||
|  |         [ | ||||||
|  |             ("1 2 3", "Three numbers"), | ||||||
|  |             ("42", "Single number"), | ||||||
|  |             ("", "No numbers"), | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| flx = Falyx( | flx = Falyx( | ||||||
|     "Argument Examples", |     "Argument Examples", | ||||||
|     program="argument_examples.py", |     program="argument_examples.py", | ||||||
| @@ -98,4 +162,30 @@ flx.add_command( | |||||||
|     argument_config=default_config, |     argument_config=default_config, | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | flx.add_command( | ||||||
|  |     key="P", | ||||||
|  |     aliases=["path"], | ||||||
|  |     description="Path Command", | ||||||
|  |     help_text="A command to test path argument parsing.", | ||||||
|  |     action=Action( | ||||||
|  |         name="test_path_arg", | ||||||
|  |         action=test_path_arg, | ||||||
|  |     ), | ||||||
|  |     style="bold #F2B3EB", | ||||||
|  |     argument_config=path_config, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | flx.add_command( | ||||||
|  |     key="N", | ||||||
|  |     aliases=["numbers"], | ||||||
|  |     description="Numbers Command", | ||||||
|  |     help_text="A command to test positional numbers argument parsing.", | ||||||
|  |     action=Action( | ||||||
|  |         name="test_positional_numbers", | ||||||
|  |         action=test_positional_numbers, | ||||||
|  |     ), | ||||||
|  |     style="bold #F2F2B3", | ||||||
|  |     argument_config=numbers_config, | ||||||
|  | ) | ||||||
|  |  | ||||||
| asyncio.run(flx.run()) | asyncio.run(flx.run()) | ||||||
|   | |||||||
| @@ -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. | ||||||
|   | |||||||
| @@ -346,6 +346,7 @@ class Command(BaseModel): | |||||||
|             FalyxMode.RUN, |             FalyxMode.RUN, | ||||||
|             FalyxMode.PREVIEW, |             FalyxMode.PREVIEW, | ||||||
|             FalyxMode.RUN_ALL, |             FalyxMode.RUN_ALL, | ||||||
|  |             FalyxMode.HELP, | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         program = f"{self.program} run " if is_cli_mode else "" |         program = f"{self.program} run " if is_cli_mode else "" | ||||||
| @@ -365,7 +366,7 @@ class Command(BaseModel): | |||||||
|         ) |         ) | ||||||
|         return ( |         return ( | ||||||
|             f"[{self.style}]{program}[/]{command_keys}", |             f"[{self.style}]{program}[/]{command_keys}", | ||||||
|             f"[dim]{self.description}[/dim]", |             f"[dim]{self.help_text or self.description}[/dim]", | ||||||
|             "", |             "", | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -8,15 +8,20 @@ This completer supports: | |||||||
| - Argument flag completion for registered commands (e.g. `--tag`, `--name`) | - Argument flag completion for registered commands (e.g. `--tag`, `--name`) | ||||||
| - Context-aware suggestions based on cursor position and argument structure | - Context-aware suggestions based on cursor position and argument structure | ||||||
| - Interactive value completions (e.g. choices and suggestions defined per argument) | - Interactive value completions (e.g. choices and suggestions defined per argument) | ||||||
|  | - File/path-friendly behavior, quoting completions with spaces automatically | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Completions are generated from: | ||||||
|  | - Registered commands in `Falyx` | ||||||
|  | - Argument metadata and `suggest_next()` from `CommandArgumentParser` | ||||||
|  |  | ||||||
| Completions are sourced from `CommandArgumentParser.suggest_next`, which analyzes |  | ||||||
| parsed tokens to determine appropriate next arguments, flags, or values. |  | ||||||
|  |  | ||||||
| Integrated with the `Falyx.prompt_session` to enhance the interactive experience. | 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 | ||||||
|  |  | ||||||
| @@ -41,9 +46,12 @@ class FalyxCompleter(Completer): | |||||||
|         - Remaining required or optional flags |         - Remaining required or optional flags | ||||||
|         - Flag value suggestions (choices or custom completions) |         - Flag value suggestions (choices or custom completions) | ||||||
|         - Next positional argument hints |         - Next positional argument hints | ||||||
|  |         - Inserts longest common prefix (LCP) completions when applicable | ||||||
|  |         - Handles special cases like quoted strings and spaces | ||||||
|  |         - Supports dynamic argument suggestions (e.g. flags, file paths, etc.) | ||||||
|  |  | ||||||
|     Args: |     Args: | ||||||
|         falyx (Falyx): The Falyx menu instance containing all command mappings and parsers. |         falyx (Falyx): The active Falyx instance providing command and parser context. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     def __init__(self, falyx: "Falyx"): |     def __init__(self, falyx: "Falyx"): | ||||||
| @@ -51,14 +59,21 @@ class FalyxCompleter(Completer): | |||||||
|  |  | ||||||
|     def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: |     def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: | ||||||
|         """ |         """ | ||||||
|         Yield completions based on the current document input. |         Compute completions for the current user input. | ||||||
|  |  | ||||||
|  |         Analyzes the input buffer, determines whether the user is typing: | ||||||
|  |         • A command key/alias | ||||||
|  |         • A flag/option | ||||||
|  |         • An argument value | ||||||
|  |  | ||||||
|  |         and yields appropriate completions. | ||||||
|  |  | ||||||
|         Args: |         Args: | ||||||
|             document (Document): The prompt_toolkit document containing the input buffer. |             document (Document): The current Prompt Toolkit document (input buffer & cursor). | ||||||
|             complete_event: The completion trigger event (unused). |             complete_event: The triggering event (TAB key, menu display, etc.) — not used here. | ||||||
|  |  | ||||||
|         Yields: |         Yields: | ||||||
|             Completion objects matching command keys or argument suggestions. |             Completion: One or more completions matching the current stub text. | ||||||
|         """ |         """ | ||||||
|         text = document.text_before_cursor |         text = document.text_before_cursor | ||||||
|         try: |         try: | ||||||
| @@ -69,7 +84,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 +100,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 | ||||||
|  |  | ||||||
| @@ -105,13 +111,15 @@ class FalyxCompleter(Completer): | |||||||
|         """ |         """ | ||||||
|         Suggest top-level command keys and aliases based on the given prefix. |         Suggest top-level command keys and aliases based on the given prefix. | ||||||
|  |  | ||||||
|  |         Filters all known commands (and `exit`, `help`, `history` built-ins) | ||||||
|  |         to only those starting with the given prefix. | ||||||
|  |  | ||||||
|         Args: |         Args: | ||||||
|             prefix (str): The user input to match against available commands. |             prefix (str): The current typed prefix. | ||||||
|  |  | ||||||
|         Yields: |         Yields: | ||||||
|             Completion: Matching keys or aliases from all registered commands. |             Completion: Matching keys or aliases from all registered commands. | ||||||
|         """ |         """ | ||||||
|         prefix = prefix.upper() |  | ||||||
|         keys = [self.falyx.exit_command.key] |         keys = [self.falyx.exit_command.key] | ||||||
|         keys.extend(self.falyx.exit_command.aliases) |         keys.extend(self.falyx.exit_command.aliases) | ||||||
|         if self.falyx.history_command: |         if self.falyx.history_command: | ||||||
| @@ -125,4 +133,64 @@ class FalyxCompleter(Completer): | |||||||
|             keys.extend(cmd.aliases) |             keys.extend(cmd.aliases) | ||||||
|         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.upper(), start_position=-len(prefix)) | ||||||
|  |             elif key.lower().startswith(prefix): | ||||||
|  |                 yield Completion(key.lower(), start_position=-len(prefix)) | ||||||
|  |  | ||||||
|  |     def _ensure_quote(self, text: str) -> str: | ||||||
|  |         """ | ||||||
|  |         Ensure that a suggestion is shell-safe by quoting if needed. | ||||||
|  |  | ||||||
|  |         Adds quotes around completions containing whitespace so they can | ||||||
|  |         be inserted into the CLI without breaking tokenization. | ||||||
|  |  | ||||||
|  |         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): | ||||||
|  |         """ | ||||||
|  |         Yield completions for the current stub using longest-common-prefix logic. | ||||||
|  |  | ||||||
|  |         Behavior: | ||||||
|  |         - If only one match → yield it fully. | ||||||
|  |         - If multiple matches share a longer prefix → insert the prefix, but also | ||||||
|  |             display all matches in the menu. | ||||||
|  |         - If no shared prefix → list all matches individually. | ||||||
|  |  | ||||||
|  |         Args: | ||||||
|  |             suggestions (list[str]): The raw suggestions to consider. | ||||||
|  |             stub (str): The currently typed prefix (used to offset insertion). | ||||||
|  |  | ||||||
|  |         Yields: | ||||||
|  |             Completion: Completion objects for the Prompt Toolkit menu. | ||||||
|  |         """ | ||||||
|  |         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 | ||||||
|  |                 ) | ||||||
|   | |||||||
							
								
								
									
										225
									
								
								falyx/falyx.py
									
									
									
									
									
								
							
							
						
						
									
										225
									
								
								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 | ||||||
| @@ -202,10 +203,12 @@ class Falyx: | |||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def is_cli_mode(self) -> bool: |     def is_cli_mode(self) -> bool: | ||||||
|  |         """Checks if the current mode is a CLI mode.""" | ||||||
|         return self.options.get("mode") in { |         return self.options.get("mode") in { | ||||||
|             FalyxMode.RUN, |             FalyxMode.RUN, | ||||||
|             FalyxMode.PREVIEW, |             FalyxMode.PREVIEW, | ||||||
|             FalyxMode.RUN_ALL, |             FalyxMode.RUN_ALL, | ||||||
|  |             FalyxMode.HELP, | ||||||
|         } |         } | ||||||
|  |  | ||||||
|     def validate_options( |     def validate_options( | ||||||
| @@ -279,7 +282,7 @@ class Falyx: | |||||||
|  |  | ||||||
|     def _get_exit_command(self) -> Command: |     def _get_exit_command(self) -> Command: | ||||||
|         """Returns the back command for the menu.""" |         """Returns the back command for the menu.""" | ||||||
|         return Command( |         exit_command = Command( | ||||||
|             key="X", |             key="X", | ||||||
|             description="Exit", |             description="Exit", | ||||||
|             action=Action("Exit", action=_noop), |             action=Action("Exit", action=_noop), | ||||||
| @@ -289,7 +292,11 @@ class Falyx: | |||||||
|             ignore_in_history=True, |             ignore_in_history=True, | ||||||
|             options_manager=self.options, |             options_manager=self.options, | ||||||
|             program=self.program, |             program=self.program, | ||||||
|  |             help_text="Exit the program.", | ||||||
|         ) |         ) | ||||||
|  |         if exit_command.arg_parser: | ||||||
|  |             exit_command.arg_parser.add_tldr_examples([("", "Exit the program.")]) | ||||||
|  |         return exit_command | ||||||
|  |  | ||||||
|     def _get_history_command(self) -> Command: |     def _get_history_command(self) -> Command: | ||||||
|         """Returns the history command for the menu.""" |         """Returns the history command for the menu.""" | ||||||
| @@ -299,6 +306,7 @@ class Falyx: | |||||||
|             command_style=OneColors.DARK_YELLOW, |             command_style=OneColors.DARK_YELLOW, | ||||||
|             aliases=["HISTORY"], |             aliases=["HISTORY"], | ||||||
|             program=self.program, |             program=self.program, | ||||||
|  |             options_manager=self.options, | ||||||
|         ) |         ) | ||||||
|         parser.add_argument( |         parser.add_argument( | ||||||
|             "-n", |             "-n", | ||||||
| @@ -333,6 +341,19 @@ class Falyx: | |||||||
|         parser.add_argument( |         parser.add_argument( | ||||||
|             "-l", "--last-result", action="store_true", help="Get the last result" |             "-l", "--last-result", action="store_true", help="Get the last result" | ||||||
|         ) |         ) | ||||||
|  |         parser.add_tldr_examples( | ||||||
|  |             [ | ||||||
|  |                 ("", "Show the full execution history."), | ||||||
|  |                 ("-n build", "Show history entries for the 'build' command."), | ||||||
|  |                 ("-s success", "Show only successful executions."), | ||||||
|  |                 ("-s error", "Show only failed executions."), | ||||||
|  |                 ("-i 3", "Show the history entry at index 3."), | ||||||
|  |                 ("-r 0", "Show the result or traceback for entry index 0."), | ||||||
|  |                 ("-l", "Show the last execution result."), | ||||||
|  |                 ("-c", "Clear the execution history."), | ||||||
|  |             ] | ||||||
|  |         ) | ||||||
|  |  | ||||||
|         return Command( |         return Command( | ||||||
|             key="Y", |             key="Y", | ||||||
|             description="History", |             description="History", | ||||||
| @@ -347,6 +368,7 @@ class Falyx: | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def get_tip(self) -> str: |     def get_tip(self) -> str: | ||||||
|  |         """Returns a random tip for the user about using Falyx.""" | ||||||
|         program = f"{self.program} run " if self.is_cli_mode else "" |         program = f"{self.program} run " if self.is_cli_mode else "" | ||||||
|         tips = [ |         tips = [ | ||||||
|             f"Use '{program}?[COMMAND]' to preview a command.", |             f"Use '{program}?[COMMAND]' to preview a command.", | ||||||
| @@ -358,11 +380,12 @@ class Falyx: | |||||||
|             f"Use '{self.program} --verbose' to enable debug logging for a menu session.", |             f"Use '{self.program} --verbose' to enable debug logging for a menu session.", | ||||||
|             f"'{self.program} --debug-hooks' will trace every before/after hook in action.", |             f"'{self.program} --debug-hooks' will trace every before/after hook in action.", | ||||||
|             f"Run commands directly from the CLI: '{self.program} run [COMMAND] [OPTIONS]'.", |             f"Run commands directly from the CLI: '{self.program} run [COMMAND] [OPTIONS]'.", | ||||||
|  |             "All [COMMAND] keys and aliases are case-insensitive.", | ||||||
|         ] |         ] | ||||||
|         if self.is_cli_mode: |         if self.is_cli_mode: | ||||||
|             tips.extend( |             tips.extend( | ||||||
|                 [ |                 [ | ||||||
|                     f"Use '{self.program} run ?' to list all commands at any time.", |                     f"Use '{self.program} help' to list all commands at any time.", | ||||||
|                     f"Use '{self.program} --never-prompt run [COMMAND] [OPTIONS]' to disable all prompts for [bold italic]just this command[/].", |                     f"Use '{self.program} --never-prompt run [COMMAND] [OPTIONS]' to disable all prompts for [bold italic]just this command[/].", | ||||||
|                     f"Use '{self.program} run --skip-confirm [COMMAND] [OPTIONS]' to skip confirmations.", |                     f"Use '{self.program} run --skip-confirm [COMMAND] [OPTIONS]' to skip confirmations.", | ||||||
|                     f"Use '{self.program} run --summary [COMMAND] [OPTIONS]' to print a post-run summary.", |                     f"Use '{self.program} run --summary [COMMAND] [OPTIONS]' to print a post-run summary.", | ||||||
| @@ -381,7 +404,35 @@ class Falyx: | |||||||
|             ) |             ) | ||||||
|         return choice(tips) |         return choice(tips) | ||||||
|  |  | ||||||
|     async def _render_help(self, tag: str = "") -> None: |     async def _render_help( | ||||||
|  |         self, tag: str = "", key: str | None = None, tldr: bool = False | ||||||
|  |     ) -> None: | ||||||
|  |         """Renders the help menu with command details, usage examples, and tips.""" | ||||||
|  |         if tldr and not key: | ||||||
|  |             if self.help_command and self.help_command.arg_parser: | ||||||
|  |                 self.help_command.arg_parser.render_tldr() | ||||||
|  |                 self.console.print(f"[bold]tip:[/bold] {self.get_tip()}") | ||||||
|  |                 return None | ||||||
|  |         if key: | ||||||
|  |             _, command, args, kwargs = await self.get_command(key, from_help=True) | ||||||
|  |             if command and tldr and command.arg_parser: | ||||||
|  |                 command.arg_parser.render_tldr() | ||||||
|  |                 self.console.print(f"[bold]tip:[/bold] {self.get_tip()}") | ||||||
|  |                 return None | ||||||
|  |             elif command and tldr and not command.arg_parser: | ||||||
|  |                 self.console.print( | ||||||
|  |                     f"[bold]No TLDR examples available for '{command.description}'.[/bold]" | ||||||
|  |                 ) | ||||||
|  |             elif command and command.arg_parser: | ||||||
|  |                 command.arg_parser.render_help() | ||||||
|  |                 self.console.print(f"[bold]tip:[/bold] {self.get_tip()}") | ||||||
|  |                 return None | ||||||
|  |             elif command and not command.arg_parser: | ||||||
|  |                 self.console.print( | ||||||
|  |                     f"[bold]No detailed help available for '{command.description}'.[/bold]" | ||||||
|  |                 ) | ||||||
|  |             else: | ||||||
|  |                 self.console.print(f"[bold]No command found for '{key}'.[/bold]") | ||||||
|         if tag: |         if tag: | ||||||
|             tag_lower = tag.lower() |             tag_lower = tag.lower() | ||||||
|             self.console.print(f"[bold]{tag_lower}:[/bold]") |             self.console.print(f"[bold]{tag_lower}:[/bold]") | ||||||
| @@ -392,13 +443,17 @@ class Falyx: | |||||||
|             ] |             ] | ||||||
|             if not commands: |             if not commands: | ||||||
|                 self.console.print(f"'{tag}'... Nothing to show here") |                 self.console.print(f"'{tag}'... Nothing to show here") | ||||||
|                 return |                 return None | ||||||
|             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"), | ||||||
|             return |                         (0, 2), | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|  |             self.console.print(f"[bold]tip:[/bold] {self.get_tip()}") | ||||||
|  |             return None | ||||||
|  |  | ||||||
|         self.console.print("[bold]help:[/bold]") |         self.console.print("[bold]help:[/bold]") | ||||||
|         for command in self.commands.values(): |         for command in self.commands.values(): | ||||||
| @@ -447,8 +502,10 @@ class Falyx: | |||||||
|             command_key="H", |             command_key="H", | ||||||
|             command_description="Help", |             command_description="Help", | ||||||
|             command_style=OneColors.LIGHT_YELLOW, |             command_style=OneColors.LIGHT_YELLOW, | ||||||
|             aliases=["?", "HELP", "LIST"], |             aliases=["HELP", "?"], | ||||||
|             program=self.program, |             program=self.program, | ||||||
|  |             options_manager=self.options, | ||||||
|  |             _is_help_command=True, | ||||||
|         ) |         ) | ||||||
|         parser.add_argument( |         parser.add_argument( | ||||||
|             "-t", |             "-t", | ||||||
| @@ -457,11 +514,27 @@ class Falyx: | |||||||
|             default="", |             default="", | ||||||
|             help="Optional tag to filter commands by.", |             help="Optional tag to filter commands by.", | ||||||
|         ) |         ) | ||||||
|  |         parser.add_argument( | ||||||
|  |             "-k", | ||||||
|  |             "--key", | ||||||
|  |             nargs="?", | ||||||
|  |             default=None, | ||||||
|  |             help="Optional command key or alias to get detailed help for.", | ||||||
|  |         ) | ||||||
|  |         parser.add_tldr_examples( | ||||||
|  |             [ | ||||||
|  |                 ("", "Show all commands."), | ||||||
|  |                 ("-k [COMMAND]", "Show detailed help for a specific command."), | ||||||
|  |                 ("-Tk [COMMAND]", "Show quick usage examples for a specific command."), | ||||||
|  |                 ("-T", "Show these quick usage examples."), | ||||||
|  |                 ("-t [TAG]", "Show commands with the specified tag."), | ||||||
|  |             ] | ||||||
|  |         ) | ||||||
|         return Command( |         return Command( | ||||||
|             key="H", |             key="H", | ||||||
|             aliases=["?", "HELP", "LIST"], |             aliases=["HELP", "?"], | ||||||
|             description="Help", |             description="Help", | ||||||
|             help_text="Show this help menu", |             help_text="Show this help menu.", | ||||||
|             action=Action("Help", self._render_help), |             action=Action("Help", self._render_help), | ||||||
|             style=OneColors.LIGHT_YELLOW, |             style=OneColors.LIGHT_YELLOW, | ||||||
|             arg_parser=parser, |             arg_parser=parser, | ||||||
| @@ -559,6 +632,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, | ||||||
| @@ -625,6 +699,7 @@ class Falyx: | |||||||
|         style: str = OneColors.DARK_RED, |         style: str = OneColors.DARK_RED, | ||||||
|         confirm: bool = False, |         confirm: bool = False, | ||||||
|         confirm_message: str = "Are you sure?", |         confirm_message: str = "Are you sure?", | ||||||
|  |         help_text: str = "Exit the program.", | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         """Updates the back command of the menu.""" |         """Updates the back command of the menu.""" | ||||||
|         self._validate_command_key(key) |         self._validate_command_key(key) | ||||||
| @@ -642,7 +717,10 @@ class Falyx: | |||||||
|             ignore_in_history=True, |             ignore_in_history=True, | ||||||
|             options_manager=self.options, |             options_manager=self.options, | ||||||
|             program=self.program, |             program=self.program, | ||||||
|  |             help_text=help_text, | ||||||
|         ) |         ) | ||||||
|  |         if self.exit_command.arg_parser: | ||||||
|  |             self.exit_command.arg_parser.add_tldr_examples([("", help_text)]) | ||||||
|  |  | ||||||
|     def add_submenu( |     def add_submenu( | ||||||
|         self, key: str, description: str, submenu: Falyx, *, style: str = OneColors.CYAN |         self, key: str, description: str, submenu: Falyx, *, style: str = OneColors.CYAN | ||||||
| @@ -655,7 +733,12 @@ class Falyx: | |||||||
|             key, description, submenu.menu, style=style, simple_help_signature=True |             key, description, submenu.menu, style=style, simple_help_signature=True | ||||||
|         ) |         ) | ||||||
|         if submenu.exit_command.key == "X": |         if submenu.exit_command.key == "X": | ||||||
|             submenu.update_exit_command(key="B", description="Back", aliases=["BACK"]) |             submenu.update_exit_command( | ||||||
|  |                 key="B", | ||||||
|  |                 description="Back", | ||||||
|  |                 aliases=["BACK"], | ||||||
|  |                 help_text="Go back to the previous menu.", | ||||||
|  |             ) | ||||||
|  |  | ||||||
|     def add_commands(self, commands: list[Command] | list[dict]) -> None: |     def add_commands(self, commands: list[Command] | list[dict]) -> None: | ||||||
|         """Adds a list of Command instances or config dicts.""" |         """Adds a list of Command instances or config dicts.""" | ||||||
| @@ -861,7 +944,7 @@ class Falyx: | |||||||
|         return False, input_str.strip() |         return False, input_str.strip() | ||||||
|  |  | ||||||
|     async def get_command( |     async def get_command( | ||||||
|         self, raw_choices: str, from_validate=False |         self, raw_choices: str, from_validate=False, from_help=False | ||||||
|     ) -> tuple[bool, Command | None, tuple, dict[str, Any]]: |     ) -> tuple[bool, Command | None, tuple, dict[str, Any]]: | ||||||
|         """ |         """ | ||||||
|         Returns the selected command based on user input. |         Returns the selected command based on user input. | ||||||
| @@ -902,11 +985,7 @@ class Falyx: | |||||||
|                 logger.info("Command '%s' selected.", run_command.key) |                 logger.info("Command '%s' selected.", run_command.key) | ||||||
|             if is_preview: |             if is_preview: | ||||||
|                 return True, run_command, args, kwargs |                 return True, run_command, args, kwargs | ||||||
|             elif self.options.get("mode") in { |             elif self.is_cli_mode or from_help: | ||||||
|                 FalyxMode.RUN, |  | ||||||
|                 FalyxMode.RUN_ALL, |  | ||||||
|                 FalyxMode.PREVIEW, |  | ||||||
|             }: |  | ||||||
|                 return False, run_command, args, kwargs |                 return False, run_command, args, kwargs | ||||||
|             try: |             try: | ||||||
|                 args, kwargs = await run_command.parse_args(input_args, from_validate) |                 args, kwargs = await run_command.parse_args(input_args, from_validate) | ||||||
| @@ -1155,7 +1234,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: | ||||||
|  |  | ||||||
|  |         - help - 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." | ||||||
| @@ -1198,8 +1329,11 @@ class Falyx: | |||||||
|             logger.debug("Enabling global debug hooks for all commands") |             logger.debug("Enabling global debug hooks for all commands") | ||||||
|             self.register_all_with_debug_hooks() |             self.register_all_with_debug_hooks() | ||||||
|  |  | ||||||
|         if self.cli_args.command == "list": |         if self.cli_args.command == "help": | ||||||
|             await self._render_help(tag=self.cli_args.tag) |             self.options.set("mode", FalyxMode.HELP) | ||||||
|  |             await self._render_help( | ||||||
|  |                 tag=self.cli_args.tag, key=self.cli_args.key, tldr=self.cli_args.tldr | ||||||
|  |             ) | ||||||
|             sys.exit(0) |             sys.exit(0) | ||||||
|  |  | ||||||
|         if self.cli_args.command == "version" or self.cli_args.version: |         if self.cli_args.command == "version" or self.cli_args.version: | ||||||
| @@ -1247,13 +1381,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 +1412,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() | ||||||
|   | |||||||
| @@ -10,3 +10,4 @@ class FalyxMode(Enum): | |||||||
|     RUN = "run" |     RUN = "run" | ||||||
|     PREVIEW = "preview" |     PREVIEW = "preview" | ||||||
|     RUN_ALL = "run-all" |     RUN_ALL = "run-all" | ||||||
|  |     HELP = "help" | ||||||
|   | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -28,6 +28,18 @@ class ArgumentState: | |||||||
|  |  | ||||||
|     arg: Argument |     arg: Argument | ||||||
|     consumed: bool = False |     consumed: bool = False | ||||||
|  |     consumed_position: int | None = None | ||||||
|  |     has_invalid_choice: bool = False | ||||||
|  |  | ||||||
|  |     def set_consumed(self, position: int | None = None) -> None: | ||||||
|  |         """Mark this argument as consumed, optionally setting the position.""" | ||||||
|  |         self.consumed = True | ||||||
|  |         self.consumed_position = position | ||||||
|  |  | ||||||
|  |     def reset(self) -> None: | ||||||
|  |         """Reset the consumed state.""" | ||||||
|  |         self.consumed = False | ||||||
|  |         self.consumed_position = None | ||||||
|  |  | ||||||
|  |  | ||||||
| @dataclass(frozen=True) | @dataclass(frozen=True) | ||||||
|   | |||||||
| @@ -4,7 +4,7 @@ Provides the argument parser infrastructure for the Falyx CLI. | |||||||
|  |  | ||||||
| This module defines the `FalyxParsers` dataclass and related utilities for building | This module defines the `FalyxParsers` dataclass and related utilities for building | ||||||
| structured CLI interfaces with argparse. It supports top-level CLI commands like | structured CLI interfaces with argparse. It supports top-level CLI commands like | ||||||
| `run`, `run-all`, `preview`, `list`, and `version`, and integrates seamlessly with | `run`, `run-all`, `preview`, `help`, and `version`, and integrates seamlessly with | ||||||
| registered `Command` objects for dynamic help, usage generation, and argument handling. | registered `Command` objects for dynamic help, usage generation, and argument handling. | ||||||
|  |  | ||||||
| Key Components: | Key Components: | ||||||
| @@ -39,7 +39,7 @@ class FalyxParsers: | |||||||
|     run: ArgumentParser |     run: ArgumentParser | ||||||
|     run_all: ArgumentParser |     run_all: ArgumentParser | ||||||
|     preview: ArgumentParser |     preview: ArgumentParser | ||||||
|     list: ArgumentParser |     help: ArgumentParser | ||||||
|     version: ArgumentParser |     version: ArgumentParser | ||||||
|  |  | ||||||
|     def parse_args(self, args: Sequence[str] | None = None) -> Namespace: |     def parse_args(self, args: Sequence[str] | None = None) -> Namespace: | ||||||
| @@ -59,7 +59,7 @@ def get_root_parser( | |||||||
|     prog: str | None = "falyx", |     prog: str | None = "falyx", | ||||||
|     usage: str | None = None, |     usage: str | None = None, | ||||||
|     description: str | None = "Falyx CLI - Run structured async command workflows.", |     description: str | None = "Falyx CLI - Run structured async command workflows.", | ||||||
|     epilog: str | None = "Tip: Use 'falyx run ?' to show available commands.", |     epilog: str | None = "Tip: Use 'falyx help' to show available commands.", | ||||||
|     parents: Sequence[ArgumentParser] | None = None, |     parents: Sequence[ArgumentParser] | None = None, | ||||||
|     prefix_chars: str = "-", |     prefix_chars: str = "-", | ||||||
|     fromfile_prefix_chars: str | None = None, |     fromfile_prefix_chars: str | None = None, | ||||||
| @@ -178,7 +178,7 @@ def get_arg_parsers( | |||||||
|     description: str | None = "Falyx CLI - Run structured async command workflows.", |     description: str | None = "Falyx CLI - Run structured async command workflows.", | ||||||
|     epilog: ( |     epilog: ( | ||||||
|         str | None |         str | None | ||||||
|     ) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.", |     ) = "Tip: Use 'falyx preview [COMMAND]' to preview any command from the CLI.", | ||||||
|     parents: Sequence[ArgumentParser] | None = None, |     parents: Sequence[ArgumentParser] | None = None, | ||||||
|     prefix_chars: str = "-", |     prefix_chars: str = "-", | ||||||
|     fromfile_prefix_chars: str | None = None, |     fromfile_prefix_chars: str | None = None, | ||||||
| @@ -196,7 +196,7 @@ def get_arg_parsers( | |||||||
|  |  | ||||||
|     This function builds the root parser and all subcommand parsers used for structured |     This function builds the root parser and all subcommand parsers used for structured | ||||||
|     CLI workflows in Falyx. It supports standard subcommands including `run`, `run-all`, |     CLI workflows in Falyx. It supports standard subcommands including `run`, `run-all`, | ||||||
|     `preview`, `list`, and `version`, and integrates with registered `Command` objects |     `preview`, `help`, and `version`, and integrates with registered `Command` objects | ||||||
|     to populate dynamic help and usage documentation. |     to populate dynamic help and usage documentation. | ||||||
|  |  | ||||||
|     Args: |     Args: | ||||||
| @@ -219,7 +219,7 @@ def get_arg_parsers( | |||||||
|  |  | ||||||
|     Returns: |     Returns: | ||||||
|         FalyxParsers: A structured container of all parsers, including `run`, `run-all`, |         FalyxParsers: A structured container of all parsers, including `run`, `run-all`, | ||||||
|                       `preview`, `list`, `version`, and the root parser. |                       `preview`, `help`, `version`, and the root parser. | ||||||
|  |  | ||||||
|     Raises: |     Raises: | ||||||
|         TypeError: If `root_parser` is not an instance of ArgumentParser or |         TypeError: If `root_parser` is not an instance of ArgumentParser or | ||||||
| @@ -240,7 +240,7 @@ def get_arg_parsers( | |||||||
|         - Use `falyx run ?[COMMAND]` from the CLI to preview a command. |         - Use `falyx run ?[COMMAND]` from the CLI to preview a command. | ||||||
|     """ |     """ | ||||||
|     if epilog is None: |     if epilog is None: | ||||||
|         epilog = f"Tip: Use '{prog} run ?' to show available commands." |         epilog = f"Tip: Use '{prog} help' to show available commands." | ||||||
|     if root_parser is None: |     if root_parser is None: | ||||||
|         parser = get_root_parser( |         parser = get_root_parser( | ||||||
|             prog=prog, |             prog=prog, | ||||||
| @@ -281,7 +281,7 @@ def get_arg_parsers( | |||||||
|             command_description = command.help_text or command.description |             command_description = command.help_text or command.description | ||||||
|             run_description.append(f"{' '*24}{command_description}") |             run_description.append(f"{' '*24}{command_description}") | ||||||
|     run_epilog = ( |     run_epilog = ( | ||||||
|         f"Tip: Use '{prog} run ?[COMMAND]' to preview commands by their key or alias." |         f"Tip: Use '{prog} preview [COMMAND]' to preview commands by their key or alias." | ||||||
|     ) |     ) | ||||||
|     run_parser = subparsers.add_parser( |     run_parser = subparsers.add_parser( | ||||||
|         "run", |         "run", | ||||||
| @@ -375,11 +375,23 @@ def get_arg_parsers( | |||||||
|     ) |     ) | ||||||
|     preview_parser.add_argument("name", help="Key, alias, or description of the command") |     preview_parser.add_argument("name", help="Key, alias, or description of the command") | ||||||
|  |  | ||||||
|     list_parser = subparsers.add_parser( |     help_parser = subparsers.add_parser("help", help="List all available commands") | ||||||
|         "list", help="List all available commands with tags" |  | ||||||
|  |     help_parser.add_argument( | ||||||
|  |         "-k", | ||||||
|  |         "--key", | ||||||
|  |         help="Show help for a specific command by its key or alias", | ||||||
|  |         default=None, | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     list_parser.add_argument( |     help_parser.add_argument( | ||||||
|  |         "-T", | ||||||
|  |         "--tldr", | ||||||
|  |         action="store_true", | ||||||
|  |         help="Show a simplified TLDR examples of a command if available", | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     help_parser.add_argument( | ||||||
|         "-t", "--tag", help="Filter commands by tag (case-insensitive)", default=None |         "-t", "--tag", help="Filter commands by tag (case-insensitive)", default=None | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
| @@ -391,6 +403,6 @@ def get_arg_parsers( | |||||||
|         run=run_parser, |         run=run_parser, | ||||||
|         run_all=run_all_parser, |         run_all=run_all_parser, | ||||||
|         preview=preview_parser, |         preview=preview_parser, | ||||||
|         list=list_parser, |         help=help_parser, | ||||||
|         version=version_parser, |         version=version_parser, | ||||||
|     ) |     ) | ||||||
|   | |||||||
| @@ -26,6 +26,18 @@ def infer_args_from_func( | |||||||
|     This utility inspects the parameters of a function and returns a list of dictionaries, |     This utility inspects the parameters of a function and returns a list of dictionaries, | ||||||
|     each of which can be passed to `CommandArgumentParser.add_argument()`. |     each of which can be passed to `CommandArgumentParser.add_argument()`. | ||||||
|  |  | ||||||
|  |     It supports: | ||||||
|  |     - Positional and keyword arguments | ||||||
|  |     - Type hints for argument types | ||||||
|  |     - Default values | ||||||
|  |     - Required vs optional arguments | ||||||
|  |     - Custom help text, choices, and suggestions via metadata | ||||||
|  |  | ||||||
|  |     Note: | ||||||
|  |         - Only parameters with kind `POSITIONAL_ONLY`, `POSITIONAL_OR_KEYWORD`, or | ||||||
|  |           `KEYWORD_ONLY` are considered. | ||||||
|  |         - Parameters with kind `VAR_POSITIONAL` or `VAR_KEYWORD` are ignored. | ||||||
|  |  | ||||||
|     Args: |     Args: | ||||||
|         func (Callable | None): The function to inspect. |         func (Callable | None): The function to inspect. | ||||||
|         arg_metadata (dict | None): Optional metadata overrides for help text, type hints, |         arg_metadata (dict | None): Optional metadata overrides for help text, type hints, | ||||||
|   | |||||||
| @@ -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.85" | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| [tool.poetry] | [tool.poetry] | ||||||
| name = "falyx" | name = "falyx" | ||||||
| version = "0.1.78" | version = "0.1.85" | ||||||
| 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) | ||||||
							
								
								
									
										96
									
								
								tests/test_falyx/test_help.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								tests/test_falyx/test_help.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,96 @@ | |||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from falyx import Falyx | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_help_command(capsys): | ||||||
|  |     flx = Falyx() | ||||||
|  |     assert flx.help_command.arg_parser.aliases[0] == "HELP" | ||||||
|  |     assert flx.help_command.arg_parser.command_key == "H" | ||||||
|  |     await flx.run_key("H") | ||||||
|  |  | ||||||
|  |     captured = capsys.readouterr() | ||||||
|  |     assert "Show this help menu" in captured.out | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_help_command_with_new_command(capsys): | ||||||
|  |     flx = Falyx() | ||||||
|  |  | ||||||
|  |     async def new_command(falyx: Falyx): | ||||||
|  |         pass | ||||||
|  |  | ||||||
|  |     flx.add_command( | ||||||
|  |         "N", | ||||||
|  |         "New Command", | ||||||
|  |         new_command, | ||||||
|  |         aliases=["TEST"], | ||||||
|  |         help_text="This is a new command.", | ||||||
|  |     ) | ||||||
|  |     await flx.run_key("H") | ||||||
|  |  | ||||||
|  |     captured = capsys.readouterr() | ||||||
|  |     assert "This is a new command." in captured.out | ||||||
|  |     assert "TEST" in captured.out and "N" in captured.out | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_render_help(capsys): | ||||||
|  |     flx = Falyx() | ||||||
|  |  | ||||||
|  |     async def sample_command(falyx: Falyx): | ||||||
|  |         pass | ||||||
|  |  | ||||||
|  |     flx.add_command( | ||||||
|  |         "S", | ||||||
|  |         "Sample Command", | ||||||
|  |         sample_command, | ||||||
|  |         aliases=["SC"], | ||||||
|  |         help_text="This is a sample command.", | ||||||
|  |     ) | ||||||
|  |     await flx._render_help() | ||||||
|  |  | ||||||
|  |     captured = capsys.readouterr() | ||||||
|  |     assert "This is a sample command." in captured.out | ||||||
|  |     assert "SC" in captured.out and "S" in captured.out | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_help_command_by_tag(capsys): | ||||||
|  |     flx = Falyx() | ||||||
|  |  | ||||||
|  |     async def tagged_command(falyx: Falyx): | ||||||
|  |         pass | ||||||
|  |  | ||||||
|  |     flx.add_command( | ||||||
|  |         "T", | ||||||
|  |         "Tagged Command", | ||||||
|  |         tagged_command, | ||||||
|  |         tags=["tag1"], | ||||||
|  |         help_text="This command is tagged.", | ||||||
|  |     ) | ||||||
|  |     await flx.run_key("H", args=("tag1",)) | ||||||
|  |  | ||||||
|  |     captured = capsys.readouterr() | ||||||
|  |     assert "tag1" in captured.out | ||||||
|  |     assert "This command is tagged." in captured.out | ||||||
|  |     assert "HELP" not in captured.out | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_help_command_empty_tags(capsys): | ||||||
|  |     flx = Falyx() | ||||||
|  |  | ||||||
|  |     async def untagged_command(falyx: Falyx): | ||||||
|  |         pass | ||||||
|  |  | ||||||
|  |     flx.add_command( | ||||||
|  |         "U", "Untagged Command", untagged_command, help_text="This command has no tags." | ||||||
|  |     ) | ||||||
|  |     await flx.run_key("H", args=("nonexistent_tag",)) | ||||||
|  |  | ||||||
|  |     captured = capsys.readouterr() | ||||||
|  |     print(captured.out) | ||||||
|  |     assert "nonexistent_tag" in captured.out | ||||||
|  |     assert "Nothing to show here" in captured.out | ||||||
							
								
								
									
										19
									
								
								tests/test_falyx/test_run.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								tests/test_falyx/test_run.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | |||||||
|  | import sys | ||||||
|  |  | ||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from falyx import Falyx | ||||||
|  | from falyx.parser import get_arg_parsers | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_run_basic(capsys): | ||||||
|  |     sys.argv = ["falyx", "run", "-h"] | ||||||
|  |     falyx_parsers = get_arg_parsers() | ||||||
|  |     assert falyx_parsers is not None, "Falyx parsers should be initialized" | ||||||
|  |     flx = Falyx() | ||||||
|  |     with pytest.raises(SystemExit): | ||||||
|  |         await flx.run(falyx_parsers) | ||||||
|  |  | ||||||
|  |     captured = capsys.readouterr() | ||||||
|  |     assert "Run a command by its key or alias." in captured.out | ||||||
| @@ -1,18 +1,311 @@ | |||||||
|  | from pathlib import Path | ||||||
|  |  | ||||||
| import pytest | import pytest | ||||||
|  |  | ||||||
|  | from falyx.exceptions import CommandArgumentError | ||||||
| from falyx.parser.command_argument_parser import CommandArgumentParser | from falyx.parser.command_argument_parser import CommandArgumentParser | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def build_default_parser(): | ||||||
|  |     p = CommandArgumentParser( | ||||||
|  |         command_key="D", aliases=["deploy"], program="argument_examples.py" | ||||||
|  |     ) | ||||||
|  |     p.add_argument("service", type=str, help="Service name.") | ||||||
|  |     p.add_argument("place", type=str, nargs="?", default="New York", help="Place.") | ||||||
|  |     p.add_argument( | ||||||
|  |         "--region", | ||||||
|  |         choices=["us-east-1", "us-west-2", "eu-west-1"], | ||||||
|  |         help="Region.", | ||||||
|  |         default="us-east-1", | ||||||
|  |     ) | ||||||
|  |     p.add_argument("-p", "--path", type=Path, help="Path.") | ||||||
|  |     p.add_argument("-v", "--verbose", action="store_true", help="Verbose.") | ||||||
|  |     p.add_argument("-t", "--tag", type=str, suggestions=["latest", "stable", "beta"]) | ||||||
|  |     p.add_argument("--numbers", type=int, nargs="*", default=[1, 2, 3], help="Nums.") | ||||||
|  |     p.add_argument("-j", "--just-a-bool", action="store_true", help="Bool.") | ||||||
|  |     p.add_argument("-a", action="store_true") | ||||||
|  |     p.add_argument("-b", action="store_true") | ||||||
|  |     return p | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
| @pytest.mark.parametrize( | async def test_parse_minimal_positional_and_defaults(): | ||||||
|     "input_tokens, expected", |     p = build_default_parser() | ||||||
|     [ |     got = await p.parse_args(["web"]) | ||||||
|         ([""], ["--help", "--tag", "-h"]), |     assert got["service"] == "web" | ||||||
|         (["--ta"], ["--tag"]), |     assert got["place"] == "New York" | ||||||
|         (["--tag"], ["analytics", "build"]), |     assert got["numbers"] == [1, 2, 3] | ||||||
|     ], |     assert got["verbose"] is False | ||||||
| ) |     assert got["tag"] is None | ||||||
| async def test_suggest_next(input_tokens, expected): |     assert got["path"] is None | ||||||
|     parser = CommandArgumentParser(...) |  | ||||||
|     parser.add_argument("--tag", choices=["analytics", "build"]) |  | ||||||
|     assert sorted(parser.suggest_next(input_tokens)) == sorted(expected) | @pytest.mark.asyncio | ||||||
|  | async def test_parse_all_keywords_and_lists_and_bools(): | ||||||
|  |     p = build_default_parser() | ||||||
|  |     got = await p.parse_args( | ||||||
|  |         [ | ||||||
|  |             "web", | ||||||
|  |             "Paris", | ||||||
|  |             "--region", | ||||||
|  |             "eu-west-1", | ||||||
|  |             "--numbers", | ||||||
|  |             "10", | ||||||
|  |             "20", | ||||||
|  |             "-30", | ||||||
|  |             "-t", | ||||||
|  |             "stable", | ||||||
|  |             "-p", | ||||||
|  |             "pyproject.toml", | ||||||
|  |             "-v", | ||||||
|  |             "-j", | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  |     assert got["service"] == "web" | ||||||
|  |     assert got["place"] == "Paris" | ||||||
|  |     assert got["region"] == "eu-west-1" | ||||||
|  |     assert got["numbers"] == [10, 20, -30] | ||||||
|  |     assert got["tag"] == "stable" | ||||||
|  |     assert isinstance(got["path"], Path) | ||||||
|  |     assert got["verbose"] is True and got["just_a_bool"] is True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_numbers_negative_values_not_flags(): | ||||||
|  |     p = build_default_parser() | ||||||
|  |     got = await p.parse_args(["web", "--numbers", "-1", "-2", "-3"]) | ||||||
|  |     assert got["numbers"] == [-1, -2, -3] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_default_list_must_match_choices_when_choices_present(): | ||||||
|  |     p = CommandArgumentParser() | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         p.add_argument( | ||||||
|  |             "--color", choices=["red", "blue"], nargs="*", default=["red", "green"] | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_default_type_for_nargs_requires_list(): | ||||||
|  |     p = CommandArgumentParser() | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         p.add_argument("--ints", type=int, nargs=2, default=1) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_choices_enforced_on_result(): | ||||||
|  |     p = CommandArgumentParser() | ||||||
|  |     p.add_argument("--env", choices=["prod", "dev"]) | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await p.parse_args(["--env", "staging"]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_posix_bundling_flags_only(): | ||||||
|  |     p = CommandArgumentParser() | ||||||
|  |     p.add_argument("-a", "--aa", action="store_true") | ||||||
|  |     p.add_argument("-b", "--bb", action="store_true") | ||||||
|  |     p.add_argument("-c", "--cc", action="store_true") | ||||||
|  |     got = await p.parse_args(["-abc"]) | ||||||
|  |     assert got["aa"] and got["bb"] and got["cc"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_posix_bundling_not_applied_when_value_like(): | ||||||
|  |     p = CommandArgumentParser() | ||||||
|  |     p.add_argument("-n", "--num", type=int) | ||||||
|  |     p.add_argument("-a", action="store_true") | ||||||
|  |     p.add_argument("-b", action="store_true") | ||||||
|  |     got = await p.parse_args(["--num", "-123", "-ab"]) | ||||||
|  |     assert got["num"] == -123 | ||||||
|  |     assert got["a"] and got["b"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def mk_tmp_tree(tmp_path: Path): | ||||||
|  |     (tmp_path / "dirA").mkdir() | ||||||
|  |     (tmp_path / "dirB").mkdir() | ||||||
|  |     (tmp_path / "file.txt").write_text("x") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_complete_initial_flags_and_suggestions(): | ||||||
|  |     p = build_default_parser() | ||||||
|  |     sugg = p.suggest_next([""], cursor_at_end_of_token=False) | ||||||
|  |     assert "--tag" in sugg and "--region" in sugg and "-v" in sugg | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_complete_flag_by_prefix(): | ||||||
|  |     p = build_default_parser() | ||||||
|  |     assert p.suggest_next(["--ta"], False) == ["--tag"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_complete_values_for_flag_choices(): | ||||||
|  |     p = build_default_parser() | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await p.parse_args(["--region"]) | ||||||
|  |     sugg = p.suggest_next(["--region"], True) | ||||||
|  |     assert set(sugg) == {"us-east-1", "us-west-2", "eu-west-1"} | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await p.parse_args(["--region", "us-"]) | ||||||
|  |     sugg2 = p.suggest_next(["--region", "us-"], False) | ||||||
|  |     assert set(sugg2) == {"us-east-1", "us-west-2"} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_complete_values_for_flag_suggestions(): | ||||||
|  |     p = build_default_parser() | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await p.parse_args(["--tag"]) | ||||||
|  |     assert set(p.suggest_next(["--tag"], True)) == {"latest", "stable", "beta"} | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await p.parse_args(["--tag", "st"]) | ||||||
|  |     assert set(p.suggest_next(["--tag", "st"], False)) == {"stable"} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_complete_mid_flag_hyphen_value_uses_previous_flag_context(): | ||||||
|  |     p = build_default_parser() | ||||||
|  |     sugg = p.suggest_next(["--numbers", "-1"], False) | ||||||
|  |     assert "--tag" not in sugg and "--region" not in sugg | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_complete_multi_value_keeps_suggesting_for_plus_star(): | ||||||
|  |     p = build_default_parser() | ||||||
|  |     sugg1 = p.suggest_next(["--numbers"], False) | ||||||
|  |     assert "--tag" not in sugg1 or True | ||||||
|  |     sugg2 = p.suggest_next(["--numbers", "1"], False) | ||||||
|  |     assert "--tag" not in sugg2 or True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_complete_path_values(tmp_path, monkeypatch): | ||||||
|  |     mk_tmp_tree(tmp_path) | ||||||
|  |     monkeypatch.chdir(tmp_path) | ||||||
|  |     p = build_default_parser() | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await p.parse_args(["--path"]) | ||||||
|  |     sugg = p.suggest_next(["--path"], True) | ||||||
|  |     assert any(s.endswith("/") for s in sugg) and "file.txt" in sugg | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await p.parse_args(["--path", "d"]) | ||||||
|  |     sugg2 = p.suggest_next(["--path", "d"], False) | ||||||
|  |     assert "dirA/" in sugg2 or "dirB/" in sugg2 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_complete_positional_path(tmp_path, monkeypatch): | ||||||
|  |     mk_tmp_tree(tmp_path) | ||||||
|  |     monkeypatch.chdir(tmp_path) | ||||||
|  |     p = CommandArgumentParser() | ||||||
|  |     p.add_argument("paths", type=Path, nargs="*") | ||||||
|  |     await p.parse_args([""]) | ||||||
|  |     s1 = p.suggest_next([""], False) | ||||||
|  |     assert "file.txt" in s1 or "dirA/" in s1 | ||||||
|  |     await p.parse_args(["fi"]) | ||||||
|  |     s2 = p.suggest_next(["fi"], False) | ||||||
|  |     assert "file.txt" in s2 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_flag_then_space_yields_flag_suggestions(): | ||||||
|  |     p = build_default_parser() | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await p.parse_args(["--tag"]) | ||||||
|  |     sugg = p.suggest_next(["--tag"], True) | ||||||
|  |     assert "latest" in sugg | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_complete_multi_value_persists_until_space_or_new_flag(): | ||||||
|  |     p = build_default_parser() | ||||||
|  |  | ||||||
|  |     s1 = p.suggest_next(["--numbers"], cursor_at_end_of_token=False) | ||||||
|  |     assert "--tag" not in s1 or True | ||||||
|  |  | ||||||
|  |     s2 = p.suggest_next(["--numbers", "1"], cursor_at_end_of_token=False) | ||||||
|  |     assert "--tag" not in s2 or True | ||||||
|  |  | ||||||
|  |     s3 = p.suggest_next(["--numbers", "1"], cursor_at_end_of_token=True) | ||||||
|  |     assert "--tag" not in s3 or True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_mid_value_suggestions_then_flags_after_space(): | ||||||
|  |     p = build_default_parser() | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await p.parse_args(["--tag", "st"]) | ||||||
|  |     s_mid = p.suggest_next(["--tag", "st"], cursor_at_end_of_token=False) | ||||||
|  |     assert set(s_mid) == {"stable"} | ||||||
|  |  | ||||||
|  |     s_after = p.suggest_next(["--tag"], cursor_at_end_of_token=True) | ||||||
|  |     assert any(opt.startswith("-") for opt in s_after) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_negative_values_then_posix_bundle(): | ||||||
|  |     p = build_default_parser() | ||||||
|  |     out = await p.parse_args(["prod", "--numbers", "-3", "-ab"]) | ||||||
|  |     assert out["numbers"] == [-3] | ||||||
|  |     assert out["a"] is True and out["b"] is True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_mid_flag_token_after_negative_value_uses_prior_flag_context(): | ||||||
|  |     p = build_default_parser() | ||||||
|  |     sugg = p.suggest_next(["--numbers", "-1"], cursor_at_end_of_token=False) | ||||||
|  |     assert "--tag" not in sugg and "--region" not in sugg | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_path_dash_prefix_is_value_not_flags(): | ||||||
|  |     p = CommandArgumentParser() | ||||||
|  |     p.add_argument("-a", action="store_true") | ||||||
|  |     p.add_argument("--path", type=Path) | ||||||
|  |  | ||||||
|  |     out = await p.parse_args(["--path", "-abc", "-a"]) | ||||||
|  |     assert str(out["path"]) == "-abc" | ||||||
|  |     assert out["a"] is True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_store_bool_optional_pair_last_one_wins(): | ||||||
|  |     p = CommandArgumentParser() | ||||||
|  |     p.add_argument("--feature", action="store_bool_optional", help="toggle feature") | ||||||
|  |  | ||||||
|  |     out0 = await p.parse_args([]) | ||||||
|  |     assert out0["feature"] is None | ||||||
|  |  | ||||||
|  |     out1 = await p.parse_args(["--feature"]) | ||||||
|  |     assert out1["feature"] is True | ||||||
|  |  | ||||||
|  |     out2 = await p.parse_args(["--no-feature"]) | ||||||
|  |     assert out2["feature"] is False | ||||||
|  |  | ||||||
|  |     out3 = await p.parse_args(["--feature", "--no-feature"]) | ||||||
|  |     assert out3["feature"] is False | ||||||
|  |  | ||||||
|  |     out4 = await p.parse_args(["--no-feature", "--feature"]) | ||||||
|  |     assert out4["feature"] is True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_invalid_choice_suppresses_then_recovers(): | ||||||
|  |     p = build_default_parser() | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await p.parse_args(["--region", "us-"]) | ||||||
|  |  | ||||||
|  |     s_suppressed = p.suggest_next(["--region", "us-"], cursor_at_end_of_token=True) | ||||||
|  |     assert s_suppressed == [] | ||||||
|  |  | ||||||
|  |     s_recover = p.suggest_next(["--region", "us-"], cursor_at_end_of_token=False) | ||||||
|  |     assert set(s_recover) == {"us-east-1", "us-west-2"} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_repeated_keyword_last_one_wins_and_guides_completion(): | ||||||
|  |     p = build_default_parser() | ||||||
|  |  | ||||||
|  |     out = await p.parse_args(["test", "--tag", "alpha", "--tag", "st"]) | ||||||
|  |     assert out["tag"] == "st" | ||||||
|  |  | ||||||
|  |     s = p.suggest_next( | ||||||
|  |         ["test", "--tag", "alpha", "--tag", "st"], cursor_at_end_of_token=False | ||||||
|  |     ) | ||||||
|  |     assert set(s) == {"stable"} | ||||||
|   | |||||||
							
								
								
									
										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