Compare commits
	
		
			5 Commits
		
	
	
		
			a25888f316
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 1ce1b2385b | |||
| 06bf0e432c | |||
| 169f228c92 | |||
| 0417a06ee4 | |||
| 55d581b870 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -15,3 +15,4 @@ build/ | ||||
| .vscode/ | ||||
| coverage.xml | ||||
| .coverage | ||||
| .config.json | ||||
|   | ||||
| @@ -25,11 +25,24 @@ async def test_args( | ||||
|     path: Path | None = None, | ||||
|     tag: str | None = None, | ||||
|     verbose: bool | None = None, | ||||
|     number: int | None = None, | ||||
|     numbers: list[int] | None = None, | ||||
|     just_a_bool: bool = False, | ||||
| ) -> str: | ||||
|     if numbers is None: | ||||
|         numbers = [] | ||||
|     if verbose: | ||||
|         print(f"Deploying {service}:{tag}:{number} to {region} at {place} from {path}...") | ||||
|     return f"{service}:{tag}:{number} deployed to {region} at {place} from {path}." | ||||
|         print( | ||||
|             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: | ||||
| @@ -55,6 +68,7 @@ def default_config(parser: CommandArgumentParser) -> None: | ||||
|         choices=["us-east-1", "us-west-2", "eu-west-1"], | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-p", | ||||
|         "--path", | ||||
|         type=Path, | ||||
|         help="Path to the configuration file.", | ||||
| @@ -65,16 +79,25 @@ def default_config(parser: CommandArgumentParser) -> None: | ||||
|         help="Enable verbose output.", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-t", | ||||
|         "--tag", | ||||
|         type=str, | ||||
|         help="Optional tag for the deployment.", | ||||
|         suggestions=["latest", "stable", "beta"], | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "--number", | ||||
|         "--numbers", | ||||
|         type=int, | ||||
|         nargs="*", | ||||
|         default=[1, 2, 3], | ||||
|         help="Optional number argument.", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-j", | ||||
|         "--just-a-bool", | ||||
|         action="store_true", | ||||
|         help="Just a boolean flag.", | ||||
|     ) | ||||
|     parser.add_tldr_examples( | ||||
|         [ | ||||
|             ("web", "Deploy 'web' to the default location (New York)"), | ||||
| @@ -84,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( | ||||
|     "Argument Examples", | ||||
|     program="argument_examples.py", | ||||
| @@ -105,4 +162,30 @@ flx.add_command( | ||||
|     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()) | ||||
|   | ||||
| @@ -346,6 +346,7 @@ class Command(BaseModel): | ||||
|             FalyxMode.RUN, | ||||
|             FalyxMode.PREVIEW, | ||||
|             FalyxMode.RUN_ALL, | ||||
|             FalyxMode.HELP, | ||||
|         } | ||||
|  | ||||
|         program = f"{self.program} run " if is_cli_mode else "" | ||||
| @@ -365,7 +366,7 @@ class Command(BaseModel): | ||||
|         ) | ||||
|         return ( | ||||
|             f"[{self.style}]{program}[/]{command_keys}", | ||||
|             f"[dim]{self.description}[/dim]", | ||||
|             f"[dim]{self.help_text or self.description}[/dim]", | ||||
|             "", | ||||
|         ) | ||||
|  | ||||
|   | ||||
| @@ -8,9 +8,13 @@ This completer supports: | ||||
| - Argument flag completion for registered commands (e.g. `--tag`, `--name`) | ||||
| - Context-aware suggestions based on cursor position and argument structure | ||||
| - 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. | ||||
| """ | ||||
| @@ -42,9 +46,12 @@ class FalyxCompleter(Completer): | ||||
|         - Remaining required or optional flags | ||||
|         - Flag value suggestions (choices or custom completions) | ||||
|         - 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: | ||||
|         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"): | ||||
| @@ -52,14 +59,21 @@ class FalyxCompleter(Completer): | ||||
|  | ||||
|     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: | ||||
|             document (Document): The prompt_toolkit document containing the input buffer. | ||||
|             complete_event: The completion trigger event (unused). | ||||
|             document (Document): The current Prompt Toolkit document (input buffer & cursor). | ||||
|             complete_event: The triggering event (TAB key, menu display, etc.) — not used here. | ||||
|  | ||||
|         Yields: | ||||
|             Completion objects matching command keys or argument suggestions. | ||||
|             Completion: One or more completions matching the current stub text. | ||||
|         """ | ||||
|         text = document.text_before_cursor | ||||
|         try: | ||||
| @@ -97,13 +111,15 @@ class FalyxCompleter(Completer): | ||||
|         """ | ||||
|         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: | ||||
|             prefix (str): The user input to match against available commands. | ||||
|             prefix (str): The current typed prefix. | ||||
|  | ||||
|         Yields: | ||||
|             Completion: Matching keys or aliases from all registered commands. | ||||
|         """ | ||||
|         prefix = prefix.upper() | ||||
|         keys = [self.falyx.exit_command.key] | ||||
|         keys.extend(self.falyx.exit_command.aliases) | ||||
|         if self.falyx.history_command: | ||||
| @@ -117,11 +133,16 @@ class FalyxCompleter(Completer): | ||||
|             keys.extend(cmd.aliases) | ||||
|         for key in keys: | ||||
|             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 the text is properly quoted for shell commands. | ||||
|         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. | ||||
| @@ -134,6 +155,22 @@ class FalyxCompleter(Completer): | ||||
|         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 | ||||
|   | ||||
							
								
								
									
										113
									
								
								falyx/falyx.py
									
									
									
									
									
								
							
							
						
						
									
										113
									
								
								falyx/falyx.py
									
									
									
									
									
								
							| @@ -203,10 +203,12 @@ class Falyx: | ||||
|  | ||||
|     @property | ||||
|     def is_cli_mode(self) -> bool: | ||||
|         """Checks if the current mode is a CLI mode.""" | ||||
|         return self.options.get("mode") in { | ||||
|             FalyxMode.RUN, | ||||
|             FalyxMode.PREVIEW, | ||||
|             FalyxMode.RUN_ALL, | ||||
|             FalyxMode.HELP, | ||||
|         } | ||||
|  | ||||
|     def validate_options( | ||||
| @@ -280,7 +282,7 @@ class Falyx: | ||||
|  | ||||
|     def _get_exit_command(self) -> Command: | ||||
|         """Returns the back command for the menu.""" | ||||
|         return Command( | ||||
|         exit_command = Command( | ||||
|             key="X", | ||||
|             description="Exit", | ||||
|             action=Action("Exit", action=_noop), | ||||
| @@ -290,7 +292,11 @@ class Falyx: | ||||
|             ignore_in_history=True, | ||||
|             options_manager=self.options, | ||||
|             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: | ||||
|         """Returns the history command for the menu.""" | ||||
| @@ -300,6 +306,7 @@ class Falyx: | ||||
|             command_style=OneColors.DARK_YELLOW, | ||||
|             aliases=["HISTORY"], | ||||
|             program=self.program, | ||||
|             options_manager=self.options, | ||||
|         ) | ||||
|         parser.add_argument( | ||||
|             "-n", | ||||
| @@ -334,6 +341,19 @@ class Falyx: | ||||
|         parser.add_argument( | ||||
|             "-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( | ||||
|             key="Y", | ||||
|             description="History", | ||||
| @@ -348,6 +368,7 @@ class Falyx: | ||||
|         ) | ||||
|  | ||||
|     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 "" | ||||
|         tips = [ | ||||
|             f"Use '{program}?[COMMAND]' to preview a command.", | ||||
| @@ -359,11 +380,12 @@ class Falyx: | ||||
|             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"Run commands directly from the CLI: '{self.program} run [COMMAND] [OPTIONS]'.", | ||||
|             "All [COMMAND] keys and aliases are case-insensitive.", | ||||
|         ] | ||||
|         if self.is_cli_mode: | ||||
|             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} run --skip-confirm [COMMAND] [OPTIONS]' to skip confirmations.", | ||||
|                     f"Use '{self.program} run --summary [COMMAND] [OPTIONS]' to print a post-run summary.", | ||||
| @@ -382,7 +404,35 @@ class Falyx: | ||||
|             ) | ||||
|         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: | ||||
|             tag_lower = tag.lower() | ||||
|             self.console.print(f"[bold]{tag_lower}:[/bold]") | ||||
| @@ -393,7 +443,7 @@ class Falyx: | ||||
|             ] | ||||
|             if not commands: | ||||
|                 self.console.print(f"'{tag}'... Nothing to show here") | ||||
|                 return | ||||
|                 return None | ||||
|             for command in commands: | ||||
|                 usage, description, _ = command.help_signature | ||||
|                 self.console.print( | ||||
| @@ -402,7 +452,8 @@ class Falyx: | ||||
|                         (0, 2), | ||||
|                     ) | ||||
|                 ) | ||||
|             return | ||||
|             self.console.print(f"[bold]tip:[/bold] {self.get_tip()}") | ||||
|             return None | ||||
|  | ||||
|         self.console.print("[bold]help:[/bold]") | ||||
|         for command in self.commands.values(): | ||||
| @@ -451,8 +502,10 @@ class Falyx: | ||||
|             command_key="H", | ||||
|             command_description="Help", | ||||
|             command_style=OneColors.LIGHT_YELLOW, | ||||
|             aliases=["?", "HELP", "LIST"], | ||||
|             aliases=["HELP", "?"], | ||||
|             program=self.program, | ||||
|             options_manager=self.options, | ||||
|             _is_help_command=True, | ||||
|         ) | ||||
|         parser.add_argument( | ||||
|             "-t", | ||||
| @@ -461,11 +514,27 @@ class Falyx: | ||||
|             default="", | ||||
|             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( | ||||
|             key="H", | ||||
|             aliases=["?", "HELP", "LIST"], | ||||
|             aliases=["HELP", "?"], | ||||
|             description="Help", | ||||
|             help_text="Show this help menu", | ||||
|             help_text="Show this help menu.", | ||||
|             action=Action("Help", self._render_help), | ||||
|             style=OneColors.LIGHT_YELLOW, | ||||
|             arg_parser=parser, | ||||
| @@ -630,6 +699,7 @@ class Falyx: | ||||
|         style: str = OneColors.DARK_RED, | ||||
|         confirm: bool = False, | ||||
|         confirm_message: str = "Are you sure?", | ||||
|         help_text: str = "Exit the program.", | ||||
|     ) -> None: | ||||
|         """Updates the back command of the menu.""" | ||||
|         self._validate_command_key(key) | ||||
| @@ -647,7 +717,10 @@ class Falyx: | ||||
|             ignore_in_history=True, | ||||
|             options_manager=self.options, | ||||
|             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( | ||||
|         self, key: str, description: str, submenu: Falyx, *, style: str = OneColors.CYAN | ||||
| @@ -660,7 +733,12 @@ class Falyx: | ||||
|             key, description, submenu.menu, style=style, simple_help_signature=True | ||||
|         ) | ||||
|         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: | ||||
|         """Adds a list of Command instances or config dicts.""" | ||||
| @@ -866,7 +944,7 @@ class Falyx: | ||||
|         return False, input_str.strip() | ||||
|  | ||||
|     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]]: | ||||
|         """ | ||||
|         Returns the selected command based on user input. | ||||
| @@ -907,11 +985,7 @@ class Falyx: | ||||
|                 logger.info("Command '%s' selected.", run_command.key) | ||||
|             if is_preview: | ||||
|                 return True, run_command, args, kwargs | ||||
|             elif self.options.get("mode") in { | ||||
|                 FalyxMode.RUN, | ||||
|                 FalyxMode.RUN_ALL, | ||||
|                 FalyxMode.PREVIEW, | ||||
|             }: | ||||
|             elif self.is_cli_mode or from_help: | ||||
|                 return False, run_command, args, kwargs | ||||
|             try: | ||||
|                 args, kwargs = await run_command.parse_args(input_args, from_validate) | ||||
| @@ -1166,7 +1240,7 @@ class Falyx: | ||||
|         This method parses CLI arguments, configures the runtime environment, and dispatches | ||||
|         execution to the appropriate command mode: | ||||
|  | ||||
|         - list - Show help output, optionally filtered by tag. | ||||
|         - 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. | ||||
| @@ -1255,8 +1329,11 @@ class Falyx: | ||||
|             logger.debug("Enabling global debug hooks for all commands") | ||||
|             self.register_all_with_debug_hooks() | ||||
|  | ||||
|         if self.cli_args.command == "list": | ||||
|             await self._render_help(tag=self.cli_args.tag) | ||||
|         if self.cli_args.command == "help": | ||||
|             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) | ||||
|  | ||||
|         if self.cli_args.command == "version" or self.cli_args.version: | ||||
|   | ||||
| @@ -10,3 +10,4 @@ class FalyxMode(Enum): | ||||
|     RUN = "run" | ||||
|     PREVIEW = "preview" | ||||
|     RUN_ALL = "run-all" | ||||
|     HELP = "help" | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -28,6 +28,18 @@ class ArgumentState: | ||||
|  | ||||
|     arg: Argument | ||||
|     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) | ||||
|   | ||||
| @@ -4,7 +4,7 @@ Provides the argument parser infrastructure for the Falyx CLI. | ||||
|  | ||||
| This module defines the `FalyxParsers` dataclass and related utilities for building | ||||
| 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. | ||||
|  | ||||
| Key Components: | ||||
| @@ -39,7 +39,7 @@ class FalyxParsers: | ||||
|     run: ArgumentParser | ||||
|     run_all: ArgumentParser | ||||
|     preview: ArgumentParser | ||||
|     list: ArgumentParser | ||||
|     help: ArgumentParser | ||||
|     version: ArgumentParser | ||||
|  | ||||
|     def parse_args(self, args: Sequence[str] | None = None) -> Namespace: | ||||
| @@ -59,7 +59,7 @@ def get_root_parser( | ||||
|     prog: str | None = "falyx", | ||||
|     usage: str | None = None, | ||||
|     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, | ||||
|     prefix_chars: str = "-", | ||||
|     fromfile_prefix_chars: str | None = None, | ||||
| @@ -178,7 +178,7 @@ def get_arg_parsers( | ||||
|     description: str | None = "Falyx CLI - Run structured async command workflows.", | ||||
|     epilog: ( | ||||
|         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, | ||||
|     prefix_chars: str = "-", | ||||
|     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 | ||||
|     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. | ||||
|  | ||||
|     Args: | ||||
| @@ -219,7 +219,7 @@ def get_arg_parsers( | ||||
|  | ||||
|     Returns: | ||||
|         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: | ||||
|         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. | ||||
|     """ | ||||
|     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: | ||||
|         parser = get_root_parser( | ||||
|             prog=prog, | ||||
| @@ -281,7 +281,7 @@ def get_arg_parsers( | ||||
|             command_description = command.help_text or command.description | ||||
|             run_description.append(f"{' '*24}{command_description}") | ||||
|     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", | ||||
| @@ -375,11 +375,23 @@ def get_arg_parsers( | ||||
|     ) | ||||
|     preview_parser.add_argument("name", help="Key, alias, or description of the command") | ||||
|  | ||||
|     list_parser = subparsers.add_parser( | ||||
|         "list", help="List all available commands with tags" | ||||
|     help_parser = subparsers.add_parser("help", help="List all available commands") | ||||
|  | ||||
|     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 | ||||
|     ) | ||||
|  | ||||
| @@ -391,6 +403,6 @@ def get_arg_parsers( | ||||
|         run=run_parser, | ||||
|         run_all=run_all_parser, | ||||
|         preview=preview_parser, | ||||
|         list=list_parser, | ||||
|         help=help_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, | ||||
|     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: | ||||
|         func (Callable | None): The function to inspect. | ||||
|         arg_metadata (dict | None): Optional metadata overrides for help text, type hints, | ||||
|   | ||||
| @@ -1 +1 @@ | ||||
| __version__ = "0.1.80" | ||||
| __version__ = "0.1.85" | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| [tool.poetry] | ||||
| name = "falyx" | ||||
| version = "0.1.80" | ||||
| version = "0.1.85" | ||||
| description = "Reliable and introspectable async CLI action framework." | ||||
| authors = ["Roland Thomas Jr <roland@rtj.dev>"] | ||||
| license = "MIT" | ||||
|   | ||||
							
								
								
									
										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 | ||||
|  | ||||
| from falyx.exceptions import CommandArgumentError | ||||
| 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.parametrize( | ||||
|     "input_tokens, expected", | ||||
| async def test_parse_minimal_positional_and_defaults(): | ||||
|     p = build_default_parser() | ||||
|     got = await p.parse_args(["web"]) | ||||
|     assert got["service"] == "web" | ||||
|     assert got["place"] == "New York" | ||||
|     assert got["numbers"] == [1, 2, 3] | ||||
|     assert got["verbose"] is False | ||||
|     assert got["tag"] is None | ||||
|     assert got["path"] is None | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_all_keywords_and_lists_and_bools(): | ||||
|     p = build_default_parser() | ||||
|     got = await p.parse_args( | ||||
|         [ | ||||
|         ([""], ["--help", "--tag", "-h"]), | ||||
|         (["--ta"], ["--tag"]), | ||||
|         (["--tag"], ["analytics", "build"]), | ||||
|     ], | ||||
| ) | ||||
| async def test_suggest_next(input_tokens, expected): | ||||
|     parser = CommandArgumentParser(...) | ||||
|     parser.add_argument("--tag", choices=["analytics", "build"]) | ||||
|     assert sorted(parser.suggest_next(input_tokens)) == sorted(expected) | ||||
|             "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"} | ||||
|   | ||||
		Reference in New Issue
	
	Block a user