From 2d305f3accab35f11e12aa556eadee78a21b7595 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 4 Feb 2026 16:19:00 -0500 Subject: [PATCH 01/30] Removed unused state argument to Cmd.complete(). --- cmd2/cmd2.py | 302 +++++++++++++-------------- cmd2/pt_utils.py | 5 +- tests/conftest.py | 2 +- tests/test_cmd2.py | 6 - tests/test_dynamic_complete_style.py | 8 +- tests/test_pt_utils.py | 4 +- 6 files changed, 151 insertions(+), 176 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 0cd4844bd..caea0d680 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -394,7 +394,7 @@ def __init__( else: self.stdout = sys.stdout - # Key used for tab completion + # Key used for completion self.completekey = completekey key_bindings = None if self.completekey != self.DEFAULT_COMPLETEKEY: @@ -424,7 +424,7 @@ def _(event: Any) -> None: # pragma: no cover self.scripts_add_to_history = True # Scripts and pyscripts add commands to history self.timing = False # Prints elapsed time for each command - # The maximum number of CompletionItems to display during tab completion. If the number of completion + # The maximum number of CompletionItems to display during completion. If the number of completion # suggestions exceeds this number, they will be displayed in the typical columnized format and will # not include the description value of the CompletionItems. self.max_completion_items: int = 50 @@ -449,7 +449,7 @@ def _(event: Any) -> None: # pragma: no cover # Allow access to your application in embedded Python shells and pyscripts via self self.self_in_py = False - # Commands to exclude from the help menu and tab completion + # Commands to exclude from the help menu and completion self.hidden_commands = ['eof', '_relative_run_script'] # Initialize history from a persistent history file (if present) @@ -538,7 +538,7 @@ def _(event: Any) -> None: # pragma: no cover # Used to keep track of whether a continuation prompt is being displayed self._at_continuation_prompt = False - # The multiline command currently being typed which is used to tab complete multiline commands. + # The multiline command currently being typed which is used to complete multiline commands. self._multiline_in_progress = '' # Characters used to draw a horizontal rule. Should not be blank. @@ -648,11 +648,11 @@ def _(event: Any) -> None: # pragma: no cover # cmd2 uses this key for sorting: # command and category names # alias, macro, settable, and shortcut names - # tab completion results when self.matches_sorted is False + # completion results when self.matches_sorted is False self.default_sort_key: Callable[[str], str] = Cmd.ALPHABETICAL_SORT_KEY ############################################################################################################ - # The following variables are used by tab completion functions. They are reset each time complete() is run + # The following variables are used by completion functions. They are reset each time complete() is run # in _reset_completion_defaults() and it is up to completer functions to set them before returning results. ############################################################################################################ @@ -664,22 +664,22 @@ def _(event: Any) -> None: # pragma: no cover # will be added if there is an unmatched opening quote self.allow_closing_quote = True - # An optional hint which prints above tab completion suggestions + # An optional hint which prints above completion suggestions self.completion_hint: str = '' # Normally cmd2 uses prompt-toolkit's formatter to columnize the list of completion suggestions. # If a custom format is preferred, write the formatted completions to this string. cmd2 will # then print it instead of the prompt-toolkit format. ANSI style sequences and newlines are supported # when using this value. Even when using formatted_completions, the full matches must still be returned - # from your completer function. ArgparseCompleter writes its tab completion tables to this string. + # from your completer function. ArgparseCompleter writes its completion tables to this string. self.formatted_completions: str = '' - # Used by complete() for prompt-toolkit tab completion + # Used by complete() for prompt-toolkit completion self.completion_matches: list[str] = [] - # Use this list if you need to display tab completion suggestions that are different than the actual text + # Use this list if you need to display completion suggestions that are different than the actual text # of the matches. For instance, if you are completing strings that contain a common delimiter and you only - # want to display the final portion of the matches as the tab completion suggestions. The full matches + # want to display the final portion of the matches as the completion suggestions. The full matches # still must be returned from your completer function. For an example, look at path_complete() which # uses this to show only the basename of paths as the suggestions. delimiter_complete() also populates # this list. These are ignored if self.formatted_completions is populated. @@ -1222,7 +1222,7 @@ def build_settables(self) -> None: """Create the dictionary of user-settable parameters.""" def get_allow_style_choices(_cli_self: Cmd) -> list[str]: - """Tab complete allow_style values.""" + """Complete allow_style values.""" return [val.name.lower() for val in ru.AllowStyle] def allow_style_type(value: str) -> ru.AllowStyle: @@ -1246,14 +1246,14 @@ def allow_style_type(value: str) -> ru.AllowStyle: ) self.add_settable( - Settable('always_show_hint', bool, 'Display tab completion hint even when completion suggestions print', self) + Settable('always_show_hint', bool, 'Display completion hint even when completion suggestions print', self) ) self.add_settable(Settable('debug', bool, "Show full traceback on exception", self)) self.add_settable(Settable('echo', bool, "Echo command issued into output", self)) self.add_settable(Settable('editor', str, "Program used by 'edit'", self)) self.add_settable(Settable('feedback_to_output', bool, "Include nonessentials in '|' and '>' results", self)) self.add_settable( - Settable('max_completion_items', int, "Maximum number of CompletionItems to display during tab completion", self) + Settable('max_completion_items', int, "Maximum number of CompletionItems to display during completion", self) ) self.add_settable( Settable( @@ -1280,7 +1280,7 @@ def allow_style(self, new_val: ru.AllowStyle) -> None: ru.ALLOW_STYLE = new_val def _completion_supported(self) -> bool: - """Return whether tab completion is supported.""" + """Return whether completion is supported.""" return self.use_rawinput and bool(self.completekey) @property @@ -1706,12 +1706,12 @@ def ppaged( rich_print_kwargs=rich_print_kwargs, ) - # ----- Methods related to tab completion ----- + # ----- Methods related to completion ----- def _reset_completion_defaults(self) -> None: - """Reset tab completion settings. + """Reset completion settings. - Needs to be called each time prompt-toolkit runs tab completion. + Needs to be called each time prompt-toolkit runs completion. """ self.allow_appended_space = True self.allow_closing_quote = True @@ -1769,14 +1769,14 @@ def get_rprompt(self) -> str | FormattedText | None: return None def tokens_for_completion(self, line: str, begidx: int, endidx: int) -> tuple[list[str], list[str]]: - """Get all tokens through the one being completed, used by tab completion functions. + """Get all tokens through the one being completed, used by completion functions. :param line: the current input line with leading whitespace removed :param begidx: the beginning index of the prefix text :param endidx: the ending index of the prefix text :return: A 2 item tuple where the items are **On Success** - - tokens: list of unquoted tokens - this is generally the list needed for tab completion functions + - tokens: list of unquoted tokens - this is generally the list needed for completion functions - raw_tokens: list of tokens with any quotes preserved = this can be used to know if a token was quoted or is missing a closing quote Both lists are guaranteed to have at least 1 item. The last item in both lists is the token being tab @@ -1840,7 +1840,7 @@ def basic_complete( endidx: int, # noqa: ARG002 match_against: Iterable[str], ) -> list[str]: - """Tab completion function that matches against a list of strings without considering line contents or cursor position. + """Completion function that matches against a list of strings without considering line contents or cursor position. The args required by this function are defined in the header of Python's cmd.py. @@ -1849,7 +1849,7 @@ def basic_complete( :param begidx: the beginning index of the prefix text :param endidx: the ending index of the prefix text :param match_against: the strings being matched against - :return: a list of possible tab completions + :return: a list of possible completions """ return [cur_match for cur_match in match_against if cur_match.startswith(text)] @@ -1862,14 +1862,14 @@ def delimiter_complete( match_against: Iterable[str], delimiter: str, ) -> list[str]: - """Perform tab completion against a list but each match is split on a delimiter. + """Perform completion against a list but each match is split on a delimiter. - Only the portion of the match being tab completed is shown as the completion suggestions. + Only the portion of the match being completed is shown as the completion suggestions. This is useful if you match against strings that are hierarchical in nature and have a common delimiter. An easy way to illustrate this concept is path completion since paths are just directories/files - delimited by a slash. If you are tab completing items in /home/user you don't get the following + delimited by a slash. If you are completing items in /home/user you don't get the following as suggestions: /home/user/file.txt /home/user/program.c @@ -1892,7 +1892,7 @@ def delimiter_complete( :param endidx: the ending index of the prefix text :param match_against: the list being matched against :param delimiter: what delimits each portion of the matches (ex: paths are delimited by a slash) - :return: a list of possible tab completions + :return: a list of possible completions """ matches = self.basic_complete(text, line, begidx, endidx, match_against) if not matches: @@ -1945,20 +1945,20 @@ def flag_based_complete( *, all_else: None | Iterable[str] | CompleterFunc = None, ) -> list[str]: - """Tab completes based on a particular flag preceding the token being completed. + """Completes based on a particular flag preceding the token being completed. :param text: the string prefix we are attempting to match (all matches must begin with it) :param line: the current input line with leading whitespace removed :param begidx: the beginning index of the prefix text :param endidx: the ending index of the prefix text :param flag_dict: dictionary whose structure is the following: - `keys` - flags (ex: -c, --create) that result in tab completion for the next argument in the + `keys` - flags (ex: -c, --create) that result in completion for the next argument in the command line `values` - there are two types of values: 1. iterable list of strings to match against (dictionaries, lists, etc.) - 2. function that performs tab completion (ex: path_complete) - :param all_else: an optional parameter for tab completing any token that isn't preceded by a flag in flag_dict - :return: a list of possible tab completions + 2. function that performs completion (ex: path_complete) + :param all_else: an optional parameter for completing any token that isn't preceded by a flag in flag_dict + :return: a list of possible completions """ # Get all tokens through the one being completed tokens, _ = self.tokens_for_completion(line, begidx, endidx) @@ -1974,11 +1974,11 @@ def flag_based_complete( if flag in flag_dict: match_against = flag_dict[flag] - # Perform tab completion using an Iterable + # Perform completion using an Iterable if isinstance(match_against, Iterable): completions_matches = self.basic_complete(text, line, begidx, endidx, match_against) - # Perform tab completion using a function + # Perform completion using a function elif callable(match_against): completions_matches = match_against(text, line, begidx, endidx) @@ -1994,7 +1994,7 @@ def index_based_complete( *, all_else: Iterable[str] | CompleterFunc | None = None, ) -> list[str]: - """Tab completes based on a fixed position in the input string. + """Completes based on a fixed position in the input string. :param text: the string prefix we are attempting to match (all matches must begin with it) :param line: the current input line with leading whitespace removed @@ -2005,9 +2005,9 @@ def index_based_complete( completion `values` - there are two types of values: 1. iterable list of strings to match against (dictionaries, lists, etc.) - 2. function that performs tab completion (ex: path_complete) - :param all_else: an optional parameter for tab completing any token that isn't at an index in index_dict - :return: a list of possible tab completions + 2. function that performs completion (ex: path_complete) + :param all_else: an optional parameter for completing any token that isn't at an index in index_dict + :return: a list of possible completions """ # Get all tokens through the one being completed tokens, _ = self.tokens_for_completion(line, begidx, endidx) @@ -2023,11 +2023,11 @@ def index_based_complete( match_against: Iterable[str] | CompleterFunc | None match_against = index_dict.get(index, all_else) - # Perform tab completion using a Iterable + # Perform completion using a Iterable if isinstance(match_against, Iterable): matches = self.basic_complete(text, line, begidx, endidx, match_against) - # Perform tab completion using a function + # Perform completion using a function elif callable(match_against): matches = match_against(text, line, begidx, endidx) @@ -2051,7 +2051,7 @@ def path_complete( :param path_filter: optional filter function that determines if a path belongs in the results this function takes a path as its argument and returns True if the path should be kept in the results - :return: a list of possible tab completions + :return: a list of possible completions """ # Used to complete ~ and ~user strings @@ -2159,7 +2159,7 @@ def complete_users() -> list[str]: # Build display_matches and add a slash to directories for index, cur_match in enumerate(matches): - # Display only the basename of this path in the tab completion suggestions + # Display only the basename of this path in the completion suggestions self.display_matches.append(os.path.basename(cur_match)) # Add a separator after directories if the next character isn't already a separator @@ -2187,9 +2187,9 @@ def shell_cmd_complete(self, text: str, line: str, begidx: int, endidx: int, *, :param endidx: the ending index of the prefix text :param complete_blank: If True, then a blank will complete all shell commands in a user's path. If False, then no completion is performed. Defaults to False to match Bash shell behavior. - :return: a list of possible tab completions + :return: a list of possible completions """ - # Don't tab complete anything if no shell command has been started + # Don't complete anything if no shell command has been started if not complete_blank and not text: return [] @@ -2203,9 +2203,9 @@ def shell_cmd_complete(self, text: str, line: str, begidx: int, endidx: int, *, ) def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, compfunc: CompleterFunc) -> list[str]: - """First tab completion function for all commands, called by complete(). + """First completion function for all commands, called by complete(). - It determines if it should tab complete for redirection (|, >, >>) or use the + It determines if it should complete for redirection (|, >, >>) or use the completer function for the current command. :param text: the string prefix we are attempting to match (all matches must begin with it) @@ -2214,7 +2214,7 @@ def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, com :param endidx: the ending index of the prefix text :param compfunc: the completer function for the current command this will be called if we aren't completing for redirection - :return: a list of possible tab completions + :return: a list of possible completions """ # Get all tokens through the one being completed. We want the raw tokens # so we can tell if redirection strings are quoted and ignore them. @@ -2257,7 +2257,7 @@ def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, com in_pipe = False in_file_redir = True - # Only tab complete after redirection tokens if redirection is allowed + # Only complete after redirection tokens if redirection is allowed elif self.allow_redirection: do_shell_completion = False do_path_completion = False @@ -2276,7 +2276,7 @@ def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, com return self.path_complete(text, line, begidx, endidx) # If there were redirection strings anywhere on the command line, then we - # are no longer tab completing for the current command + # are no longer completing for the current command if has_redirection: return [] @@ -2425,7 +2425,7 @@ def _perform_completion( text = text_to_remove + text begidx = actual_begidx - # Attempt tab completion for redirection first, and if that isn't occurring, + # Attempt completion for redirection first, and if that isn't occurring, # call the completer function for the current command self.completion_matches = self._redirect_complete(text, line, begidx, endidx, completer_func) @@ -2446,7 +2446,7 @@ def _perform_completion( if not completion_token_quote: add_quote = False - # This is the tab completion text that will appear on the command line. + # This is the completion text that will appear on the command line. common_prefix = os.path.commonprefix(self.completion_matches) if self.matches_delimited: @@ -2455,7 +2455,7 @@ def _perform_completion( if ' ' in common_prefix or any(' ' in match for match in self.display_matches): add_quote = True - # If there is a tab completion and any match has a space, then add an opening quote + # If there is a completion and any match has a space, then add an opening quote elif any(' ' in match for match in self.completion_matches): add_quote = True @@ -2465,7 +2465,7 @@ def _perform_completion( self.completion_matches = [completion_token_quote + match for match in self.completion_matches] - # Check if we need to remove text from the beginning of tab completions + # Check if we need to remove text from the beginning of completions elif text_to_remove: self.completion_matches = [match.replace(text_to_remove, '', 1) for match in self.completion_matches] @@ -2476,112 +2476,94 @@ def _perform_completion( def complete( self, text: str, - state: int, - line: str | None = None, - begidx: int | None = None, - endidx: int | None = None, + line: str, + begidx: int, + endidx: int, custom_settings: utils.CustomCompletionSettings | None = None, ) -> str | None: - """Override of cmd's complete method which returns the next possible completion for 'text'. - - This completer function is called by prompt-toolkit as complete(text, state), for state in 0, 1, 2, …, - until it returns a non-string value. It should return the next possible completion starting with text. - - Since prompt-toolkit suppresses any exception raised in completer functions, they can be difficult to debug. - Therefore, this function wraps the actual tab completion logic and prints to stderr any exception that - occurs before returning control to prompt-toolkit. + """Handle completion for an input line. :param text: the current word that user is typing - :param state: non-negative integer - :param line: optional current input line - :param begidx: optional beginning index of text - :param endidx: optional ending index of text - :param custom_settings: used when not tab completing the main command line + :param line: current input line + :param begidx: beginning index of text + :param endidx: ending index of text + :param custom_settings: used when not completing the main command line :return: the next possible completion for text or None """ try: - if state == 0: - self._reset_completion_defaults() - - # If line is provided, use it and indices. Otherwise fallback to empty (for safety) - if line is None: - line = "" - if begidx is None: - begidx = 0 - if endidx is None: - endidx = 0 - - # Check if we are completing a multiline command - if self._at_continuation_prompt: - # lstrip and prepend the previously typed portion of this multiline command - lstripped_previous = self._multiline_in_progress.lstrip() - line = lstripped_previous + line - - # Increment the indexes to account for the prepended text - begidx = len(lstripped_previous) + begidx - endidx = len(lstripped_previous) + endidx + self._reset_completion_defaults() + + # Check if we are completing a multiline command + if self._at_continuation_prompt: + # lstrip and prepend the previously typed portion of this multiline command + lstripped_previous = self._multiline_in_progress.lstrip() + line = lstripped_previous + line + + # Increment the indexes to account for the prepended text + begidx = len(lstripped_previous) + begidx + endidx = len(lstripped_previous) + endidx + else: + # lstrip the original line + orig_line = line + line = orig_line.lstrip() + num_stripped = len(orig_line) - len(line) + + # Calculate new indexes for the stripped line. If the cursor is at a position before the end of a + # line of spaces, then the following math could result in negative indexes. Enforce a max of 0. + begidx = max(begidx - num_stripped, 0) + endidx = max(endidx - num_stripped, 0) + + # Shortcuts are not word break characters when completing. Therefore, shortcuts become part + # of the text variable if there isn't a word break, like a space, after it. We need to remove it + # from text and update the indexes. This only applies if we are at the beginning of the command line. + shortcut_to_restore = '' + if begidx == 0 and custom_settings is None: + for shortcut, _ in self.statement_parser.shortcuts: + if text.startswith(shortcut): + # Save the shortcut to restore later + shortcut_to_restore = shortcut + + # Adjust text and where it begins + text = text[len(shortcut_to_restore) :] + begidx += len(shortcut_to_restore) + break else: - # lstrip the original line - orig_line = line - line = orig_line.lstrip() - num_stripped = len(orig_line) - len(line) - - # Calculate new indexes for the stripped line. If the cursor is at a position before the end of a - # line of spaces, then the following math could result in negative indexes. Enforce a max of 0. - begidx = max(begidx - num_stripped, 0) - endidx = max(endidx - num_stripped, 0) - - # Shortcuts are not word break characters when tab completing. Therefore, shortcuts become part - # of the text variable if there isn't a word break, like a space, after it. We need to remove it - # from text and update the indexes. This only applies if we are at the beginning of the command line. - shortcut_to_restore = '' - if begidx == 0 and custom_settings is None: - for shortcut, _ in self.statement_parser.shortcuts: - if text.startswith(shortcut): - # Save the shortcut to restore later - shortcut_to_restore = shortcut - - # Adjust text and where it begins - text = text[len(shortcut_to_restore) :] - begidx += len(shortcut_to_restore) - break - else: - # No shortcut was found. Complete the command token. - parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(add_help=False) - parser.add_argument( - 'command', - metavar="COMMAND", - help="command, alias, or macro name", - choices=self._get_commands_aliases_and_macros_for_completion(), - suppress_tab_hint=True, - ) - custom_settings = utils.CustomCompletionSettings(parser) + # No shortcut was found. Complete the command token. + parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(add_help=False) + parser.add_argument( + 'command', + metavar="COMMAND", + help="command, alias, or macro name", + choices=self._get_commands_aliases_and_macros_for_completion(), + suppress_tab_hint=True, + ) + custom_settings = utils.CustomCompletionSettings(parser) - self._perform_completion(text, line, begidx, endidx, custom_settings) + self._perform_completion(text, line, begidx, endidx, custom_settings) - # Check if we need to restore a shortcut in the tab completions - # so it doesn't get erased from the command line - if shortcut_to_restore: - self.completion_matches = [shortcut_to_restore + match for match in self.completion_matches] + # Check if we need to restore a shortcut in the completions + # so it doesn't get erased from the command line + if shortcut_to_restore: + self.completion_matches = [shortcut_to_restore + match for match in self.completion_matches] - # If we have one result and we are at the end of the line, then add a space if allowed - if len(self.completion_matches) == 1 and endidx == len(line) and self.allow_appended_space: - self.completion_matches[0] += ' ' + # If we have one result and we are at the end of the line, then add a space if allowed + if len(self.completion_matches) == 1 and endidx == len(line) and self.allow_appended_space: + self.completion_matches[0] += ' ' - # Sort matches if they haven't already been sorted - if not self.matches_sorted: - self.completion_matches.sort(key=self.default_sort_key) - self.display_matches.sort(key=self.default_sort_key) - self.matches_sorted = True + # Sort matches if they haven't already been sorted + if not self.matches_sorted: + self.completion_matches.sort(key=self.default_sort_key) + self.display_matches.sort(key=self.default_sort_key) + self.matches_sorted = True - # Swap between COLUMN and MULTI_COLUMN style based on the number of matches if not using READLINE_LIKE - if len(self.completion_matches) > self.max_column_completion_results: - self.session.complete_style = CompleteStyle.MULTI_COLUMN - else: - self.session.complete_style = CompleteStyle.COLUMN + # Swap between COLUMN and MULTI_COLUMN style based on the number of matches if not using READLINE_LIKE + if len(self.completion_matches) > self.max_column_completion_results: + self.session.complete_style = CompleteStyle.MULTI_COLUMN + else: + self.session.complete_style = CompleteStyle.COLUMN try: - return self.completion_matches[state] + return self.completion_matches[0] except IndexError: return None @@ -2603,7 +2585,7 @@ def complete( self.completion_hint = err_str return None except Exception as ex: # noqa: BLE001 - # Insert a newline so the exception doesn't print in the middle of the command line being tab completed + # Insert a newline so the exception doesn't print in the middle of the command line being completed exception_console = ru.Cmd2ExceptionConsole() with exception_console.capture() as capture: exception_console.print() @@ -2678,7 +2660,7 @@ def _get_settable_completion_items(self) -> list[CompletionItem]: return results def _get_commands_aliases_and_macros_for_completion(self) -> list[CompletionItem]: - """Return a list of visible commands, aliases, and macros for tab completion.""" + """Return a list of visible commands, aliases, and macros for completion.""" results: list[CompletionItem] = [] # Add commands @@ -3027,7 +3009,7 @@ def _complete_statement(self, line: str) -> Statement: try: self._at_continuation_prompt = True - # Save the command line up to this point for tab completion + # Save the command line up to this point for completion self._multiline_in_progress = line + '\n' # Get next line of this command @@ -3390,27 +3372,27 @@ def read_input( ) -> str: """Read input from appropriate stdin value. - Also supports tab completion and up-arrow history while input is being entered. + Also supports completion and up-arrow history while input is being entered. :param prompt: prompt to display to user :param history: optional list of strings to use for up-arrow history. If completion_mode is CompletionMode.COMMANDS and this is None, then cmd2's command list history will be used. The passed in history will not be edited. It is the caller's responsibility to add the returned input to history if desired. Defaults to None. - :param completion_mode: tells what type of tab completion to support. Tab completion only works when + :param completion_mode: tells what type of completion to support. Completion only works when self.use_rawinput is True and sys.stdin is a terminal. Defaults to CompletionMode.NONE. The following optional settings apply when completion_mode is CompletionMode.CUSTOM: :param preserve_quotes: if True, then quoted tokens will keep their quotes when processed by - ArgparseCompleter. This is helpful in cases when you're tab completing + ArgparseCompleter. This is helpful in cases when you're completing flag-like tokens (e.g. -o, --option) and you don't want them to be treated as argparse flags when quoted. Set this to True if you plan on passing the string to argparse with the tokens still quoted. A maximum of one of these should be provided: :param choices: iterable of accepted values for single argument :param choices_provider: function that provides choices for single argument - :param completer: tab completion function that provides choices for single argument - :param parser: an argument parser which supports the tab completion of multiple arguments + :param completer: completion function that provides choices for single argument + :param parser: an argument parser which supports the completion of multiple arguments :return: the line read from stdin with all trailing new lines removed :raises Exception: any exceptions raised by prompt() """ @@ -3613,7 +3595,7 @@ def _build_alias_create_parser(cls) -> Cmd2ArgumentParser: (" alias create save_results print_results \">\" out.txt\n", Cmd2Style.COMMAND_LINE), "\n\n", ( - "Since aliases are resolved during parsing, tab completion will function as it would " + "Since aliases are resolved during parsing, completion will function as it would " "for the actual command the alias resolves to." ), ) @@ -3773,7 +3755,7 @@ def macro_arg_complete( begidx: int, endidx: int, ) -> list[str]: - """Tab completes arguments to a macro. + """Completes arguments to a macro. Its default behavior is to call path_complete, but you can override this as needed. @@ -3783,7 +3765,7 @@ def macro_arg_complete( :param line: the current input line with leading whitespace removed :param begidx: the beginning index of the prefix text :param endidx: the ending index of the prefix text - :return: a list of possible tab completions + :return: a list of possible completions """ return self.path_complete(text, line, begidx, endidx) @@ -3856,8 +3838,8 @@ def _build_macro_create_parser(cls) -> Cmd2ArgumentParser: (" macro create show_results print_results -type {1} \"|\" less", Cmd2Style.COMMAND_LINE), "\n\n", ( - "Since macros don't resolve until after you press Enter, their arguments tab complete as paths. " - "This default behavior changes if custom tab completion for macro arguments has been implemented." + "Since macros don't resolve until after you press Enter, their arguments complete as paths. " + "This default behavior changes if custom completion for macro arguments has been implemented." ), ) macro_create_parser.epilog = macro_create_parser.create_text_group("Notes", macro_create_notes) @@ -4451,7 +4433,7 @@ def select(self, opts: str | list[str] | list[tuple[Any, str | None]], prompt: s @classmethod def _build_base_set_parser(cls) -> Cmd2ArgumentParser: - # When tab completing value, we recreate the set command parser with a value argument specific to + # When completing value, we recreate the set command parser with a value argument specific to # the settable being edited. To make this easier, define a base parser with all the common elements. set_description = Text.assemble( "Set a settable parameter or show current settings of parameters.", @@ -4486,7 +4468,7 @@ def complete_set_value( settable_parser = self._build_base_set_parser() # Settables with choices list the values of those choices instead of the arg name - # in help text and this shows in tab completion hints. Set metavar to avoid this. + # in help text and this shows in completion hints. Set metavar to avoid this. arg_name = 'value' settable_parser.add_argument( arg_name, @@ -4678,7 +4660,7 @@ def _set_up_py_shell_env(self, interp: InteractiveConsole) -> _SavedCmd2Env: # Set up sys module for the Python console self._reset_py_display() - # Enable tab completion if readline is available + # Enable completion if readline is available if not sys.platform.startswith('win'): import readline import rlcompleter @@ -4687,7 +4669,7 @@ def _set_up_py_shell_env(self, interp: InteractiveConsole) -> _SavedCmd2Env: cmd2_env.completer = readline.get_completer() # Set the completer to use the interpreter's locals - readline.set_completer(rlcompleter.Completer(interp.locals).complete) + readline.set_completer(rlcompleter.Completer(interp.locals).complete) # type: ignore[arg-type] # Use the correct binding based on whether LibEdit or Readline is being used if 'libedit' in (readline.__doc__ or ''): diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index 96ea27486..4efe1ebcc 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -63,9 +63,8 @@ def get_completions(self, document: Document, _complete_event: object) -> Iterab endidx = cursor_pos text = line[begidx:endidx] - # Call cmd2's complete method. - # We pass state=0 to trigger the completion calculation. - self.cmd_app.complete(text, 0, line=line, begidx=begidx, endidx=endidx, custom_settings=self.custom_settings) + # Call cmd2's complete method + self.cmd_app.complete(text, line=line, begidx=begidx, endidx=endidx, custom_settings=self.custom_settings) # Print formatted completions (tables) above the prompt if present if self.cmd_app.formatted_completions: diff --git a/tests/conftest.py b/tests/conftest.py index 666c4c016..63d877bc7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -144,7 +144,7 @@ def get_endidx() -> int: return endidx # Run the prompt-toolkit tab completion function with mocks in place - res = app.complete(text, 0, line, begidx, endidx) + res = app.complete(text, line, begidx, endidx) # If the completion resulted in a hint being set, then print it now # so that it can be captured by tests using capsys. diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index f42add634..b60a1d12a 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -3722,12 +3722,6 @@ def test_multiline_complete_statement_keyboard_interrupt(multiline_app, monkeypa poutput_mock.assert_called_with('^C') -def test_complete_optional_args_defaults(base_app) -> None: - # Test that complete can be called with just text and state - complete_val = base_app.complete('test', 0) - assert complete_val is None - - def test_prompt_session_init_no_console_error(monkeypatch): from prompt_toolkit.shortcuts import PromptSession diff --git a/tests/test_dynamic_complete_style.py b/tests/test_dynamic_complete_style.py index f6160c3f4..b3bec21b5 100644 --- a/tests/test_dynamic_complete_style.py +++ b/tests/test_dynamic_complete_style.py @@ -34,11 +34,11 @@ def test_dynamic_complete_style(app): # Complete 'foo' which has 10 items (> 7) # text='item', state=0, line='foo item', begidx=4, endidx=8 - app.complete('item', 0, 'foo item', 4, 8) + app.complete('item', 'foo item', 4, 8) assert app.session.complete_style == CompleteStyle.MULTI_COLUMN # Complete 'bar' which has 5 items (<= 7) - app.complete('item', 0, 'bar item', 4, 8) + app.complete('item', 'bar item', 4, 8) assert app.session.complete_style == CompleteStyle.COLUMN @@ -47,12 +47,12 @@ def test_dynamic_complete_style_custom_limit(app): app.max_column_completion_results = 3 # Complete 'bar' which has 5 items (> 3) - app.complete('item', 0, 'bar item', 4, 8) + app.complete('item', 'bar item', 4, 8) assert app.session.complete_style == CompleteStyle.MULTI_COLUMN # Change limit to 15 app.max_column_completion_results = 15 # Complete 'foo' which has 10 items (<= 15) - app.complete('item', 0, 'foo item', 4, 8) + app.complete('item', 'foo item', 4, 8) assert app.session.complete_style == CompleteStyle.COLUMN diff --git a/tests/test_pt_utils.py b/tests/test_pt_utils.py index 681be468d..7de0462bf 100644 --- a/tests/test_pt_utils.py +++ b/tests/test_pt_utils.py @@ -167,7 +167,7 @@ def test_get_completions_basic(self, mock_cmd_app): # Verify cmd_app.complete was called correctly # begidx = cursor_position - len(text) = 11 - 3 = 8 - mock_cmd_app.complete.assert_called_once_with(text, 0, line=line, begidx=8, endidx=11, custom_settings=None) + mock_cmd_app.complete.assert_called_once_with(text, line=line, begidx=8, endidx=11, custom_settings=None) # Verify completions assert len(completions) == 2 @@ -320,7 +320,7 @@ def test_get_completions_custom_delimiters(self, mock_cmd_app): list(completer.get_completions(document, None)) # text should be "arg", begidx=4, endidx=7 - mock_cmd_app.complete.assert_called_with("arg", 0, line="cmd#arg", begidx=4, endidx=7, custom_settings=None) + mock_cmd_app.complete.assert_called_with("arg", line="cmd#arg", begidx=4, endidx=7, custom_settings=None) class TestCmd2History: From ffe2a6111ad09e7869d41be7b269ab61d1b69b7f Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 4 Feb 2026 16:35:09 -0500 Subject: [PATCH 02/30] Updated comments to just say 'complete' instead of 'tab complete'. --- cmd2/argparse_completer.py | 28 ++++++++++----------- cmd2/argparse_custom.py | 50 +++++++++++++++++++------------------- cmd2/constants.py | 3 +-- cmd2/exceptions.py | 8 +++--- cmd2/parsing.py | 2 +- cmd2/utils.py | 24 +++++++++--------- 6 files changed, 57 insertions(+), 58 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 7f4a62093..47ded62c9 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -1,4 +1,4 @@ -"""Module defines the ArgparseCompleter class which provides argparse-based tab completion to cmd2 apps. +"""Module defines the ArgparseCompleter class which provides argparse-based completion to cmd2 apps. See the header of argparse_custom.py for instructions on how to use these features. """ @@ -47,7 +47,7 @@ def _build_hint(parser: argparse.ArgumentParser, arg_action: argparse.Action) -> str: - """Build tab completion hint for a given argument.""" + """Build completion hint for a given argument.""" # Check if hinting is disabled for this argument suppress_hint = arg_action.get_suppress_tab_hint() # type: ignore[attr-defined] if suppress_hint or arg_action.help == argparse.SUPPRESS: @@ -140,17 +140,17 @@ class _NoResultsError(CompletionError): def __init__(self, parser: argparse.ArgumentParser, arg_action: argparse.Action) -> None: """CompletionError which occurs when there are no results. - If hinting is allowed, then its message will be a hint about the argument being tab completed. + If hinting is allowed, then its message will be a hint about the argument being completed. - :param parser: ArgumentParser instance which owns the action being tab completed - :param arg_action: action being tab completed. + :param parser: ArgumentParser instance which owns the action being completed + :param arg_action: action being completed. """ # Set apply_style to False because we don't want hints to look like errors super().__init__(_build_hint(parser, arg_action), apply_style=False) class ArgparseCompleter: - """Automatic command line tab completion based on argparse parameters.""" + """Automatic command line completion based on argparse parameters.""" def __init__( self, parser: argparse.ArgumentParser, cmd2_app: 'Cmd', *, parent_tokens: dict[str, list[str]] | None = None @@ -202,9 +202,9 @@ def complete( :param begidx: the beginning index of the prefix text :param endidx: the ending index of the prefix text :param tokens: list of argument tokens being passed to the parser - :param cmd_set: if tab completing a command, the CommandSet the command's function belongs to, if applicable. + :param cmd_set: if completing a command, the CommandSet the command's function belongs to, if applicable. Defaults to None. - :raises CompletionError: for various types of tab completion errors + :raises CompletionError: for various types of completion errors """ if not tokens: return [] @@ -493,8 +493,8 @@ def _handle_last_token( def _complete_flags( self, text: str, line: str, begidx: int, endidx: int, matched_flags: list[str] ) -> list[CompletionItem]: - """Tab completion routine for a parsers unused flags.""" - # Build a list of flags that can be tab completed + """Completion routine for a parsers unused flags.""" + # Build a list of flags that can be completed match_against = [] for flag in self._flags: @@ -513,7 +513,7 @@ def _complete_flags( action = self._flag_to_action[flag] matched_actions.setdefault(action, []).append(flag) - # For tab completion suggestions, group matched flags by action + # For completion suggestions, group matched flags by action results: list[CompletionItem] = [] for action, option_strings in matched_actions.items(): flag_text = ', '.join(option_strings) @@ -639,7 +639,7 @@ def _complete_arg( *, cmd_set: CommandSet | None = None, ) -> list[str]: - """Tab completion routine for an argparse argument. + """Completion routine for an argparse argument. :return: list of completions :raises CompletionError: if the completer or choices function this calls raises one. @@ -707,7 +707,7 @@ def _complete_arg( # Add the namespace to the keyword arguments for the function we are calling kwargs[ARG_TOKENS] = arg_tokens - # Check if the argument uses a specific tab completion function to provide its choices + # Check if the argument uses a specific completion function to provide its choices if isinstance(arg_choices, ChoicesCallable) and arg_choices.is_completer: args.extend([text, line, begidx, endidx]) results = arg_choices.completer(*args, **kwargs) # type: ignore[arg-type] @@ -733,7 +733,7 @@ def _complete_arg( used_values = consumed_arg_values.get(arg_state.action.dest, []) completion_items = [choice for choice in completion_items if choice not in used_values] - # Do tab completion on the choices + # Do completion on the choices results = self._cmd2_app.basic_complete(text, line, begidx, endidx, completion_items) if not results: diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 3afec8d0f..0ce85db57 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -29,16 +29,16 @@ parser.add_argument('-f', nargs=(3, 5)) -**Tab Completion** +**Completion** -cmd2 uses its ArgparseCompleter class to enable argparse-based tab completion +cmd2 uses its ArgparseCompleter class to enable argparse-based completion on all commands that use the @with_argparse wrappers. Out of the box you get -tab completion of commands, subcommands, and flag names, as well as instructive +completion of commands, subcommands, and flag names, as well as instructive hints about the current argument that print when tab is pressed. In addition, -you can add tab completion for each argument's values using parameters passed +you can add completion for each argument's values using parameters passed to add_argument(). -Below are the 3 add_argument() parameters for enabling tab completion of an +Below are the 3 add_argument() parameters for enabling completion of an argument's value. Only one can be used at a time. ``choices`` - pass a list of values to the choices parameter. @@ -59,7 +59,7 @@ def my_choices_provider(self): parser.add_argument("arg", choices_provider=my_choices_provider) -``completer`` - pass a tab completion function that does custom completion. +``completer`` - pass a function that does custom completion. cmd2 provides a few completer methods for convenience (e.g., path_complete, delimiter_complete) @@ -93,13 +93,13 @@ def my_choices_provider(self): ArgparseCompleter will pass its ``cmd2.Cmd`` app instance as the first positional argument. -Of the 3 tab completion parameters, ``choices`` is the only one where argparse +Of the 3 completion parameters, ``choices`` is the only one where argparse validates user input against items in the choices list. This is because the -other 2 parameters are meant to tab complete data sets that are viewed as +other 2 parameters are meant to complete data sets that are viewed as dynamic. Therefore it is up to the developer to validate if the user has typed an acceptable value for these arguments. -There are times when what's being tab completed is determined by a previous +There are times when what's being completed is determined by a previous argument on the command line. In these cases, ArgparseCompleter can pass a dictionary that maps the command line tokens up through the one being completed to their argparse argument name. To receive this dictionary, your @@ -111,14 +111,14 @@ def my_choices_provider(self, arg_tokens) def my_completer(self, text, line, begidx, endidx, arg_tokens) All values of the arg_tokens dictionary are lists, even if a particular -argument expects only 1 token. Since ArgparseCompleter is for tab completion, +argument expects only 1 token. Since ArgparseCompleter is for completion, it does not convert the tokens to their actual argument types or validate their values. All tokens are stored in the dictionary as the raw strings provided on the command line. It is up to the developer to determine if the user entered the correct argument type (e.g. int) and validate their values. CompletionItem Class - This class was added to help in cases where -uninformative data is being tab completed. For instance, tab completing ID +uninformative data is being completed. For instance, completing ID numbers isn't very helpful to a user without context. Returning a list of CompletionItems instead of a regular string for completion results will signal the ArgparseCompleter to output the completion results in a table of completion @@ -135,7 +135,7 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) 3 Yet another item -The left-most column is the actual value being tab completed and its header is +The left-most column is the actual value being completed and its header is that value's name. The right column header is defined using the ``descriptive_headers`` parameter of add_argument(), which is a list of header names that defaults to ["Description"]. The right column values come from the @@ -174,7 +174,7 @@ def get_items(self) -> list[CompletionItems]: CompletionItem(3, ["Yet another item", False, ""]), ] - This is what the user will see during tab completion. + This is what the user will see during completion. ITEM_ID Item Name Checked Out Due Date ─────────────────────────────────────────────────────── @@ -390,7 +390,7 @@ def __new__(cls, value: object, *_args: Any, **_kwargs: Any) -> Self: def __init__(self, value: object, descriptive_data: Sequence[Any], *args: Any) -> None: """CompletionItem Initializer. - :param value: the value being tab completed + :param value: the value being completed :param descriptive_data: a list of descriptive data to display in the columns that follow the completion value. The number of items in this list must equal the number of descriptive headers defined for the argument. @@ -421,7 +421,7 @@ def orig_value(self) -> Any: @runtime_checkable class ChoicesProviderFuncBase(Protocol): - """Function that returns a list of choices in support of tab completion.""" + """Function that returns a list of choices in support of completion.""" def __call__(self) -> list[str]: # pragma: no cover """Enable instances to be called like functions.""" @@ -429,7 +429,7 @@ def __call__(self) -> list[str]: # pragma: no cover @runtime_checkable class ChoicesProviderFuncWithTokens(Protocol): - """Function that returns a list of choices in support of tab completion and accepts a dictionary of prior arguments.""" + """Function that returns a list of choices in support of completion and accepts a dictionary of prior arguments.""" def __call__(self, *, arg_tokens: dict[str, list[str]] = {}) -> list[str]: # pragma: no cover # noqa: B006 """Enable instances to be called like functions.""" @@ -440,7 +440,7 @@ def __call__(self, *, arg_tokens: dict[str, list[str]] = {}) -> list[str]: # pr @runtime_checkable class CompleterFuncBase(Protocol): - """Function to support tab completion with the provided state of the user prompt.""" + """Function to support completion with the provided state of the user prompt.""" def __call__( self, @@ -454,7 +454,7 @@ def __call__( @runtime_checkable class CompleterFuncWithTokens(Protocol): - """Function to support tab completion with the provided state of the user prompt, accepts a dictionary of prior args.""" + """Function to support completion with the provided state of the user prompt, accepts a dictionary of prior args.""" def __call__( self, @@ -484,7 +484,7 @@ def __init__( ) -> None: """Initialize the ChoiceCallable instance. - :param is_completer: True if to_call is a tab completion routine which expects + :param is_completer: True if to_call is a completion routine which expects the args: text, line, begidx, endidx :param to_call: the callable object that will be called to provide choices for the argument. """ @@ -822,8 +822,8 @@ def _add_argument_wrapper( # Added args used by ArgparseCompleter :param choices_provider: function that provides choices for this argument - :param completer: tab completion function that provides choices for this argument - :param suppress_tab_hint: when ArgparseCompleter has no results to show during tab completion, it displays the + :param completer: completion function that provides choices for this argument + :param suppress_tab_hint: when ArgparseCompleter has no results to show during completion, it displays the current argument's help text as a hint. Set this to True to suppress the hint. If this argument's help text is set to argparse.SUPPRESS, then tab hints will not display regardless of the value passed for suppress_tab_hint. Defaults to False. @@ -988,7 +988,7 @@ def _match_argument_wrapper(self: argparse.ArgumentParser, action: argparse.Acti # Patch argparse.ArgumentParser with accessors for ap_completer_type attribute ############################################################################################################ -# An ArgumentParser attribute which specifies a subclass of ArgparseCompleter for custom tab completion behavior on a +# An ArgumentParser attribute which specifies a subclass of ArgparseCompleter for custom completion behavior on a # given parser. If this is None or not present, then cmd2 will use argparse_completer.DEFAULT_AP_COMPLETER when tab # completing a parser's arguments ATTR_AP_COMPLETER_TYPE = 'ap_completer_type' @@ -1018,7 +1018,7 @@ def _ArgumentParser_set_ap_completer_type(self: argparse.ArgumentParser, ap_comp To call: ``parser.set_ap_completer_type(ap_completer_type)`` :param self: ArgumentParser being edited - :param ap_completer_type: the custom ArgparseCompleter-based class to use when tab completing arguments for this parser + :param ap_completer_type: the custom ArgparseCompleter-based class to use when completing arguments for this parser """ setattr(self, ATTR_AP_COMPLETER_TYPE, ap_completer_type) @@ -1460,9 +1460,9 @@ def __init__( ) -> None: """Initialize the Cmd2ArgumentParser instance, a custom ArgumentParser added by cmd2. - :param ap_completer_type: optional parameter which specifies a subclass of ArgparseCompleter for custom tab completion + :param ap_completer_type: optional parameter which specifies a subclass of ArgparseCompleter for custom completion behavior on this parser. If this is None or not present, then cmd2 will use - argparse_completer.DEFAULT_AP_COMPLETER when tab completing this parser's arguments + argparse_completer.DEFAULT_AP_COMPLETER when completing this parser's arguments """ kwargs: dict[str, bool] = {} if sys.version_info >= (3, 14): diff --git a/cmd2/constants.py b/cmd2/constants.py index 5d3351ebb..c27935ef0 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -5,8 +5,7 @@ INFINITY = float('inf') -# Used for command parsing, output redirection, tab completion and word -# breaks. Do not change. +# Used for command parsing, output redirection, completion, and word breaks. Do not change. QUOTES = ['"', "'"] REDIRECTION_PIPE = '|' REDIRECTION_OUTPUT = '>' diff --git a/cmd2/exceptions.py b/cmd2/exceptions.py index 052c93eed..4e6083802 100644 --- a/cmd2/exceptions.py +++ b/cmd2/exceptions.py @@ -25,16 +25,16 @@ class CommandSetRegistrationError(Exception): class CompletionError(Exception): - """Raised during tab completion operations to report any sort of error you want printed. + """Raised during completion operations to report any sort of error you want printed. This can also be used just to display a message, even if it's not an error. For instance, ArgparseCompleter raises - CompletionErrors to display tab completion hints and sets apply_style to False so hints aren't colored like error text. + CompletionErrors to display completion hints and sets apply_style to False so hints aren't colored like error text. Example use cases: - - Reading a database to retrieve a tab completion data set failed + - Reading a database to retrieve a completion data set failed - A previous command line argument that determines the data set being completed is invalid - - Tab completion hints + - Completion hints """ def __init__(self, *args: Any, apply_style: bool = True) -> None: diff --git a/cmd2/parsing.py b/cmd2/parsing.py index 8f902c089..bf36498de 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -533,7 +533,7 @@ def parse_command_only(self, rawinput: str) -> Statement: Multiline commands are identified, but terminators and output redirection are not parsed. - This method is used by tab completion code and therefore must not + This method is used by completion code and therefore must not generate an exception if there are unclosed quotes. The [cmd2.parsing.Statement][] object returned by this method can at most diff --git a/cmd2/utils.py b/cmd2/utils.py index 367debd7a..e35b0756d 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -89,7 +89,7 @@ def __init__( validation fails, which will be caught and displayed to the user by the set command. For example, setting this to int ensures the input is a valid integer. Specifying bool automatically provides - tab completion for 'true' and 'false' and uses a built-in function + completion for 'true' and 'false' and uses a built-in function for conversion and validation. :param description: A concise string that describes the purpose of this setting. :param settable_object: The object that owns the attribute being made settable (e.g. self). @@ -105,18 +105,18 @@ def __init__( old_value: Any - the parameter's old value new_value: Any - the parameter's new value - The following optional settings provide tab completion for a parameter's values. - They correspond to the same settings in argparse-based tab completion. A maximum + The following optional settings provide completion for a parameter's values. + They correspond to the same settings in argparse-based completion. A maximum of one of these should be provided. :param choices: iterable of accepted values :param choices_provider: function that provides choices for this argument - :param completer: tab completion function that provides choices for this argument + :param completer: completion function that provides choices for this argument """ if val_type is bool: def get_bool_choices(_: str) -> list[str]: - """Tab complete lowercase boolean values.""" + """Complete lowercase boolean values.""" return ['true', 'false'] val_type = to_bool @@ -733,32 +733,32 @@ def get_defining_class(meth: Callable[..., Any]) -> type[Any] | None: class CompletionMode(Enum): - """Enum for what type of tab completion to perform in cmd2.Cmd.read_input().""" + """Enum for what type of completion to perform in cmd2.Cmd.read_input().""" - # Tab completion will be disabled during read_input() call + # Completion will be disabled during read_input() call # Use of custom up-arrow history supported NONE = 1 - # read_input() will tab complete cmd2 commands and their arguments + # read_input() will complete cmd2 commands and their arguments # cmd2's command line history will be used for up arrow if history is not provided. # Otherwise use of custom up-arrow history supported. COMMANDS = 2 - # read_input() will tab complete based on one of its following parameters: + # read_input() will complete based on one of its following parameters: # choices, choices_provider, completer, parser # Use of custom up-arrow history supported CUSTOM = 3 class CustomCompletionSettings: - """Used by cmd2.Cmd.complete() to tab complete strings other than command arguments.""" + """Used by cmd2.Cmd.complete() to complete strings other than command arguments.""" def __init__(self, parser: argparse.ArgumentParser, *, preserve_quotes: bool = False) -> None: """CustomCompletionSettings initializer. - :param parser: arg parser defining format of string being tab completed + :param parser: arg parser defining format of string being completed :param preserve_quotes: if True, then quoted tokens will keep their quotes when processed by - ArgparseCompleter. This is helpful in cases when you're tab completing + ArgparseCompleter. This is helpful in cases when you're completing flag-like tokens (e.g. -o, --option) and you don't want them to be treated as argparse flags when quoted. Set this to True if you plan on passing the string to argparse with the tokens still quoted. From 2da8a1ab90f482ae5d2a5e9e2bb4bdbc70d52f35 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 5 Feb 2026 16:20:17 -0500 Subject: [PATCH 03/30] First pass at restructuring completion functions and classes. - Moved completion classes to new completion.py module. - Removed all completion state variables from Cmd class and placed them in Completions class. - All completer functions now return a Completions object. - Moved completion exception handling to to pt_utils.py. --- cmd2/__init__.py | 5 +- cmd2/argparse_completer.py | 181 ++++++----- cmd2/argparse_custom.py | 112 +------ cmd2/cmd2.py | 641 +++++++++++++++++-------------------- cmd2/completion.py | 190 +++++++++++ cmd2/pt_utils.py | 67 ++-- 6 files changed, 626 insertions(+), 570 deletions(-) create mode 100644 cmd2/completion.py diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 1313bc1a9..d7f7b62f8 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -15,7 +15,6 @@ from .argparse_custom import ( Cmd2ArgumentParser, Cmd2AttributeWrapper, - CompletionItem, register_argparse_argument_parameter, set_default_argument_parser_type, ) @@ -25,6 +24,7 @@ CommandSet, with_default_category, ) +from .completion import CompletionItem from .constants import ( COMMAND_NAME, DEFAULT_SHORTCUTS, @@ -60,7 +60,6 @@ # Argparse Exports 'Cmd2ArgumentParser', 'Cmd2AttributeWrapper', - 'CompletionItem', 'register_argparse_argument_parameter', 'set_default_ap_completer_type', 'set_default_argument_parser_type', @@ -71,6 +70,8 @@ 'Statement', # Colors "Color", + # Completion + 'CompletionItem', # Decorators 'with_argument_list', 'with_argparser', diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 47ded62c9..1222a1ae0 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -30,11 +30,14 @@ from .argparse_custom import ( ChoicesCallable, - ChoicesProviderFuncWithTokens, - CompletionItem, generate_range_error, ) from .command_definition import CommandSet +from .completion import ( + ChoicesProviderFuncWithTokens, + CompletionItem, + Completions, +) from .exceptions import CompletionError from .styles import Cmd2Style @@ -153,7 +156,11 @@ class ArgparseCompleter: """Automatic command line completion based on argparse parameters.""" def __init__( - self, parser: argparse.ArgumentParser, cmd2_app: 'Cmd', *, parent_tokens: dict[str, list[str]] | None = None + self, + parser: argparse.ArgumentParser, + cmd2_app: 'Cmd', + *, + parent_tokens: dict[str, list[str]] | None = None, ) -> None: """Create an ArgparseCompleter. @@ -193,8 +200,15 @@ def __init__( self._subcommand_action = action def complete( - self, text: str, line: str, begidx: int, endidx: int, tokens: list[str], *, cmd_set: CommandSet | None = None - ) -> list[str]: + self, + text: str, + line: str, + begidx: int, + endidx: int, + tokens: list[str], + *, + cmd_set: CommandSet | None = None, + ) -> Completions: """Complete text using argparse metadata. :param text: the string prefix we are attempting to match (all matches must begin with it) @@ -204,10 +218,11 @@ def complete( :param tokens: list of argument tokens being passed to the parser :param cmd_set: if completing a command, the CommandSet the command's function belongs to, if applicable. Defaults to None. + :return: a Completions object :raises CompletionError: for various types of completion errors """ if not tokens: - return [] + return Completions() # Positionals args that are left to parse remaining_positionals = deque(self._positional_actions) @@ -223,7 +238,7 @@ def complete( flag_arg_state: _ArgumentState | None = None # Non-reusable flags that we've parsed - matched_flags: list[str] = [] + used_flags: list[str] = [] # Keeps track of arguments we've seen and any tokens they consumed consumed_arg_values: dict[str, list[str]] = {} # dict(arg_name -> list[tokens]) @@ -282,14 +297,14 @@ def consume_argument(arg_state: _ArgumentState, token: str) -> None: if len(candidates) == 1: action = self._flag_to_action[candidates[0]] if action: - self._update_mutex_groups(action, completed_mutex_groups, matched_flags, remaining_positionals) + self._update_mutex_groups(action, completed_mutex_groups, used_flags, remaining_positionals) if isinstance(action, (argparse._AppendAction, argparse._AppendConstAction, argparse._CountAction)): # Flags with action set to append, append_const, and count can be reused # Therefore don't erase any tokens already consumed for this flag consumed_arg_values.setdefault(action.dest, []) else: # This flag is not reusable, so mark that we've seen it - matched_flags.extend(action.option_strings) + used_flags.extend(action.option_strings) # It's possible we already have consumed values for this flag if it was used # earlier in the command line. Reset them now for this use of it. @@ -332,16 +347,14 @@ def consume_argument(arg_state: _ArgumentState, token: str) -> None: return completer.complete(text, line, begidx, endidx, tokens[token_index + 1 :], cmd_set=cmd_set) # Invalid subcommand entered, so no way to complete remaining tokens - return [] + return Completions() # Otherwise keep track of the argument pos_arg_state = _ArgumentState(action) # Check if we have a positional to consume this token if pos_arg_state is not None: - self._update_mutex_groups( - pos_arg_state.action, completed_mutex_groups, matched_flags, remaining_positionals - ) + self._update_mutex_groups(pos_arg_state.action, completed_mutex_groups, used_flags, remaining_positionals) consume_argument(pos_arg_state, token) # No more flags are allowed if this is a REMAINDER argument @@ -369,7 +382,7 @@ def consume_argument(arg_state: _ArgumentState, token: str) -> None: pos_arg_state, remaining_positionals, consumed_arg_values, - matched_flags, + used_flags, skip_remaining_flags, cmd_set, ) @@ -378,7 +391,7 @@ def _update_mutex_groups( self, arg_action: argparse.Action, completed_mutex_groups: dict[argparse._MutuallyExclusiveGroup, argparse.Action], - matched_flags: list[str], + used_flags: list[str], remaining_positionals: deque[argparse.Action], ) -> None: """Update mutex groups state.""" @@ -396,7 +409,7 @@ def _update_mutex_groups( if group_action == arg_action: continue if group_action in self._flag_to_action.values(): - matched_flags.extend(group_action.option_strings) + used_flags.extend(group_action.option_strings) elif group_action in remaining_positionals: remaining_positionals.remove(group_action) break @@ -411,10 +424,10 @@ def _handle_last_token( pos_arg_state: _ArgumentState | None, remaining_positionals: deque[argparse.Action], consumed_arg_values: dict[str, list[str]], - matched_flags: list[str], + used_flags: list[str], skip_remaining_flags: bool, cmd_set: CommandSet | None, - ) -> list[str]: + ) -> Completions: """Perform final completion step handling positionals and flags.""" # Check if we are completing a flag name. This check ignores strings with a length of one, like '-'. # This is because that could be the start of a negative number which may be a valid completion for @@ -423,17 +436,17 @@ def _handle_last_token( if _looks_like_flag(text, self._parser) and not skip_remaining_flags: if flag_arg_state and isinstance(flag_arg_state.min, int) and flag_arg_state.count < flag_arg_state.min: raise _UnfinishedFlagError(flag_arg_state) - return cast(list[str], self._complete_flags(text, line, begidx, endidx, matched_flags)) + return self._complete_flags(text, line, begidx, endidx, used_flags) # Check if we are completing a flag's argument if flag_arg_state is not None: - results = self._complete_arg(text, line, begidx, endidx, flag_arg_state, consumed_arg_values, cmd_set=cmd_set) + completions = self._complete_arg(text, line, begidx, endidx, flag_arg_state, consumed_arg_values, cmd_set=cmd_set) # If we have results, then return them - if results: - if not self._cmd2_app.completion_hint: - self._cmd2_app.completion_hint = _build_hint(self._parser, flag_arg_state.action) - return results + if completions: + if not completions.completion_hint: + completions.completion_hint = _build_hint(self._parser, flag_arg_state.action) + return completions # Otherwise, print a hint if the flag isn't finished or text isn't possibly the start of a flag if ( @@ -442,39 +455,41 @@ def _handle_last_token( or skip_remaining_flags ): raise _NoResultsError(self._parser, flag_arg_state.action) - return [] + return Completions() # Otherwise check if we have a positional to complete if pos_arg_state is None and remaining_positionals: pos_arg_state = _ArgumentState(remaining_positionals.popleft()) if pos_arg_state is not None: - results = self._complete_arg(text, line, begidx, endidx, pos_arg_state, consumed_arg_values, cmd_set=cmd_set) + completions = self._complete_arg(text, line, begidx, endidx, pos_arg_state, consumed_arg_values, cmd_set=cmd_set) + # Fallback to flags if allowed if not skip_remaining_flags: if _looks_like_flag(text, self._parser) or _single_prefix_char(text, self._parser): - flag_results = self._complete_flags(text, line, begidx, endidx, matched_flags) - results.extend(cast(list[str], flag_results)) + flag_completions = self._complete_flags(text, line, begidx, endidx, used_flags) + completions.matches.extend(flag_completions.matches) + completions.display_matches.extend(flag_completions.display_matches) elif ( not text - and not results + and not completions and (isinstance(pos_arg_state.max, int) and pos_arg_state.count >= pos_arg_state.max) ): - flag_results = self._complete_flags(text, line, begidx, endidx, matched_flags) - if flag_results: - return cast(list[str], flag_results) + flag_completions = self._complete_flags(text, line, begidx, endidx, used_flags) + if flag_completions: + return flag_completions # If we have results, then return them - if results: + if completions: # Don't overwrite an existing hint if ( - not self._cmd2_app.completion_hint + not completions.completion_hint and not isinstance(pos_arg_state.action, argparse._SubParsersAction) and not _looks_like_flag(text, self._parser) and not _single_prefix_char(text, self._parser) ): - self._cmd2_app.completion_hint = _build_hint(self._parser, pos_arg_state.action) - return results + completions.completion_hint = _build_hint(self._parser, pos_arg_state.action) + return completions # Otherwise, print a hint if text isn't possibly the start of a flag if not _single_prefix_char(text, self._parser) or skip_remaining_flags: @@ -486,35 +501,33 @@ def _handle_last_token( if not skip_remaining_flags and (not text or _single_prefix_char(text, self._parser) or not remaining_positionals): # Reset any completion settings that may have been set by functions which actually had no matches. # Otherwise, those settings could alter how the flags are displayed. - self._cmd2_app._reset_completion_defaults() - return cast(list[str], self._complete_flags(text, line, begidx, endidx, matched_flags)) - return [] + return self._complete_flags(text, line, begidx, endidx, used_flags) + + return Completions() - def _complete_flags( - self, text: str, line: str, begidx: int, endidx: int, matched_flags: list[str] - ) -> list[CompletionItem]: + def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, used_flags: list[str]) -> Completions: """Completion routine for a parsers unused flags.""" # Build a list of flags that can be completed - match_against = [] + match_against: list[str] = [] for flag in self._flags: # Make sure this flag hasn't already been used - if flag not in matched_flags: + if flag not in used_flags: # Make sure this flag isn't considered hidden action = self._flag_to_action[flag] if action.help != argparse.SUPPRESS: match_against.append(flag) - matches = self._cmd2_app.basic_complete(text, line, begidx, endidx, match_against) + matched_flags = self._cmd2_app.basic_complete(text, line, begidx, endidx, match_against).matches # Build a dictionary linking actions with their matched flag names matched_actions: dict[argparse.Action, list[str]] = {} - for flag in matches: + for flag in matched_flags: action = self._flag_to_action[flag] matched_actions.setdefault(action, []).append(flag) # For completion suggestions, group matched flags by action - results: list[CompletionItem] = [] + completions = Completions() for action, option_strings in matched_actions.items(): flag_text = ', '.join(option_strings) @@ -522,38 +535,44 @@ def _complete_flags( if not action.required: flag_text = '[' + flag_text + ']' - self._cmd2_app.display_matches.append(flag_text) # Use the first option string as the completion result for this action - results.append(CompletionItem(option_strings[0], [action.help or ''])) - return results + completions.matches.append(CompletionItem(option_strings[0], [action.help or ''])) + completions.display_matches.append(flag_text) + + return completions - def _format_completions(self, arg_state: _ArgumentState, completions: list[str] | list[CompletionItem]) -> list[str]: - """Format CompletionItems into hint table.""" + def _prepare_formatted_exceptions(self, arg_state: _ArgumentState, completions: Completions) -> None: + """Format CompletionItems into hint table. + + This method modifies the completions object in-place. + + :param completions: the object to modify by populating its formatted_exceptions + """ # Nothing to do if we don't have at least 2 completions which are all CompletionItems - if len(completions) < 2 or not all(isinstance(c, CompletionItem) for c in completions): - return cast(list[str], completions) + if len(completions) < 2 or not all(isinstance(c, CompletionItem) for c in completions.matches): + return - items = cast(list[CompletionItem], completions) + completion_items = cast(list[CompletionItem], completions.matches) # Check if the data being completed have a numerical type - all_nums = all(isinstance(c.orig_value, numbers.Number) for c in items) + all_nums = all(isinstance(c.orig_value, numbers.Number) for c in completion_items) # Sort CompletionItems before building the hint table - if not self._cmd2_app.matches_sorted: + if not completions.matches_sorted: # If all orig_value types are numbers, then sort by that value if all_nums: - items.sort(key=lambda c: c.orig_value) + completion_items.sort(key=lambda c: c.orig_value) # Otherwise sort as strings else: - items.sort(key=self._cmd2_app.default_sort_key) - self._cmd2_app.matches_sorted = True + completion_items.sort(key=self._cmd2_app.default_sort_key) + completions.matches_sorted = True # Check if there are too many CompletionItems to display as a table if len(completions) <= self._cmd2_app.max_completion_items: if isinstance(arg_state.action, argparse._SubParsersAction) or ( arg_state.action.metavar == "COMMAND" and arg_state.action.dest == "command" ): - return cast(list[str], completions) + return # If a metavar was defined, use that instead of the dest field destination = arg_state.action.metavar or arg_state.action.dest @@ -576,17 +595,16 @@ def _format_completions(self, arg_state: _ArgumentState, completions: list[str] # Build the hint table hint_table = Table(*headers, box=SIMPLE_HEAD, show_edge=False, border_style=Cmd2Style.TABLE_BORDER) - for item in items: + for item in completion_items: hint_table.add_row(item, *item.descriptive_data) # Generate the hint table string console = Cmd2GeneralConsole() with console.capture() as capture: console.print(hint_table, end="", soft_wrap=False) - self._cmd2_app.formatted_completions = capture.get() - return cast(list[str], completions) + completions.formatted_completions = capture.get() - def complete_subcommand_help(self, text: str, line: str, begidx: int, endidx: int, tokens: list[str]) -> list[str]: + def complete_subcommand_help(self, text: str, line: str, begidx: int, endidx: int, tokens: list[str]) -> Completions: """Supports cmd2's help command in the completion of subcommand names. :param text: the string prefix we are attempting to match (all matches must begin with it) @@ -594,7 +612,7 @@ def complete_subcommand_help(self, text: str, line: str, begidx: int, endidx: in :param begidx: the beginning index of the prefix text :param endidx: the ending index of the prefix text :param tokens: arguments passed to command/subcommand - :return: list of subcommand completions. + :return: a Completions object """ # If our parser has subcommands, we must examine the tokens and check if they are subcommands # If so, we will let the subcommand's parser handle the rest of the tokens via another ArgparseCompleter. @@ -609,7 +627,7 @@ def complete_subcommand_help(self, text: str, line: str, begidx: int, endidx: in # Since this is the last token, we will attempt to complete it return self._cmd2_app.basic_complete(text, line, begidx, endidx, self._subcommand_action.choices) break - return [] + return Completions() def print_help(self, tokens: list[str], file: IO[str] | None = None) -> None: """Supports cmd2's help command in the printing of help text. @@ -638,13 +656,14 @@ def _complete_arg( consumed_arg_values: dict[str, list[str]], *, cmd_set: CommandSet | None = None, - ) -> list[str]: + ) -> Completions: """Completion routine for an argparse argument. - :return: list of completions - :raises CompletionError: if the completer or choices function this calls raises one. + :return: a Completions object + :raises CompletionError: if the completer or choices function this calls raises one """ # Check if the arg provides choices to the user + choices_sorted = False arg_choices: list[str] | list[CompletionItem] | ChoicesCallable if arg_state.action.choices is not None: if isinstance(arg_state.action, argparse._SubParsersAction): @@ -661,12 +680,12 @@ def _complete_arg( arg_choices = list(arg_state.action.choices) if not arg_choices: - return [] + return Completions() # If these choices are numbers, then sort them now if all(isinstance(x, numbers.Number) for x in arg_choices): arg_choices.sort() - self._cmd2_app.matches_sorted = True + choices_sorted = True # Since choices can be various types, make sure they are all strings for index, choice in enumerate(arg_choices): @@ -676,7 +695,7 @@ def _complete_arg( else: choices_attr = arg_state.action.get_choices_callable() # type: ignore[attr-defined] if choices_attr is None: - return [] + return Completions() arg_choices = choices_attr # If we are going to call a completer/choices function, then set up the common arguments @@ -710,7 +729,7 @@ def _complete_arg( # Check if the argument uses a specific completion function to provide its choices if isinstance(arg_choices, ChoicesCallable) and arg_choices.is_completer: args.extend([text, line, begidx, endidx]) - results = arg_choices.completer(*args, **kwargs) # type: ignore[arg-type] + completions = arg_choices.completer(*args, **kwargs) # type: ignore[arg-type] # Otherwise use basic_complete on the choices else: @@ -734,14 +753,12 @@ def _complete_arg( completion_items = [choice for choice in completion_items if choice not in used_values] # Do completion on the choices - results = self._cmd2_app.basic_complete(text, line, begidx, endidx, completion_items) - - if not results: - # Reset the value for matches_sorted. This is because completion of flag names - # may still be attempted after we return and they haven't been sorted yet. - self._cmd2_app.matches_sorted = False - return [] - return self._format_completions(arg_state, results) + completions = self._cmd2_app.basic_complete(text, line, begidx, endidx, completion_items) + if choices_sorted: + completions.matches_sorted = choices_sorted + + self._prepare_formatted_exceptions(arg_state, completions) + return completions # The default ArgparseCompleter class for a cmd2 app diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 102dfb2f7..aa1c48ec4 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -269,16 +269,13 @@ def get_items(self) -> list[CompletionItems]: Any, ClassVar, NoReturn, - Protocol, cast, - runtime_checkable, ) from rich.console import ( Group, RenderableType, ) -from rich.protocol import is_renderable from rich.table import Column from rich.text import Text from rich_argparse import ( @@ -289,14 +286,17 @@ def get_items(self) -> list[CompletionItems]: RichHelpFormatter, ) -if sys.version_info >= (3, 11): - from typing import Self -else: - from typing_extensions import Self - - from . import constants from . import rich_utils as ru +from .completion import ( + ChoicesProviderFunc, + ChoicesProviderFuncBase, + ChoicesProviderFuncWithTokens, + CompleterFunc, + CompleterFuncBase, + CompleterFuncWithTokens, + CompletionItem, +) from .rich_utils import Cmd2RichArgparseConsole from .styles import Cmd2Style @@ -375,100 +375,6 @@ def set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None: req_args.append(action.dest) -class CompletionItem(str): # noqa: SLOT000 - """Completion item with descriptive text attached. - - See header of this file for more information - """ - - def __new__(cls, value: object, *_args: Any, **_kwargs: Any) -> Self: - """Responsible for creating and returning a new instance, called before __init__ when an object is instantiated.""" - return super().__new__(cls, value) - - def __init__(self, value: object, descriptive_data: Sequence[Any], *args: Any) -> None: - """CompletionItem Initializer. - - :param value: the value being completed - :param descriptive_data: a list of descriptive data to display in the columns that follow - the completion value. The number of items in this list must equal - the number of descriptive headers defined for the argument. - :param args: args for str __init__ - """ - super().__init__(*args) - - # Make sure all objects are renderable by a Rich table. - renderable_data = [obj if is_renderable(obj) else str(obj) for obj in descriptive_data] - - # Convert strings containing ANSI style sequences to Rich Text objects for correct display width. - self.descriptive_data = ru.prepare_objects_for_rendering(*renderable_data) - - # Save the original value to support CompletionItems as argparse choices. - # cmd2 has patched argparse so input is compared to this value instead of the CompletionItem instance. - self._orig_value = value - - @property - def orig_value(self) -> Any: - """Read-only property for _orig_value.""" - return self._orig_value - - -############################################################################################################ -# Class and functions related to ChoicesCallable -############################################################################################################ - - -@runtime_checkable -class ChoicesProviderFuncBase(Protocol): - """Function that returns a list of choices in support of completion.""" - - def __call__(self) -> list[str]: # pragma: no cover - """Enable instances to be called like functions.""" - - -@runtime_checkable -class ChoicesProviderFuncWithTokens(Protocol): - """Function that returns a list of choices in support of completion and accepts a dictionary of prior arguments.""" - - def __call__(self, *, arg_tokens: dict[str, list[str]] = {}) -> list[str]: # pragma: no cover # noqa: B006 - """Enable instances to be called like functions.""" - - -ChoicesProviderFunc = ChoicesProviderFuncBase | ChoicesProviderFuncWithTokens - - -@runtime_checkable -class CompleterFuncBase(Protocol): - """Function to support completion with the provided state of the user prompt.""" - - def __call__( - self, - text: str, - line: str, - begidx: int, - endidx: int, - ) -> list[str]: # pragma: no cover - """Enable instances to be called like functions.""" - - -@runtime_checkable -class CompleterFuncWithTokens(Protocol): - """Function to support completion with the provided state of the user prompt, accepts a dictionary of prior args.""" - - def __call__( - self, - text: str, - line: str, - begidx: int, - endidx: int, - *, - arg_tokens: dict[str, list[str]] = {}, # noqa: B006 - ) -> list[str]: # pragma: no cover - """Enable instances to be called like functions.""" - - -CompleterFunc = CompleterFuncBase | CompleterFuncWithTokens - - class ChoicesCallable: """Enables using a callable as the choices provider for an argparse argument. diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 23598098e..42115f0a0 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -49,9 +49,7 @@ Iterable, Mapping, ) -from types import ( - FrameType, -) +from types import FrameType from typing import ( IO, TYPE_CHECKING, @@ -64,10 +62,16 @@ ) import rich.box -from rich.console import Console, Group, RenderableType +from rich.console import ( + Group, + RenderableType, +) from rich.highlighter import ReprHighlighter from rich.rule import Rule -from rich.style import Style, StyleType +from rich.style import ( + Style, + StyleType, +) from rich.table import ( Column, Table, @@ -84,12 +88,7 @@ ) from . import rich_utils as ru from . import string_utils as su -from .argparse_custom import ( - ChoicesProviderFunc, - Cmd2ArgumentParser, - CompleterFunc, - CompletionItem, -) +from .argparse_custom import Cmd2ArgumentParser from .clipboard import ( get_paste_buffer, write_to_paste_buffer, @@ -98,6 +97,12 @@ CommandFunc, CommandSet, ) +from .completion import ( + ChoicesProviderFunc, + CompleterFunc, + CompletionItem, + Completions, +) from .constants import ( CLASS_ATTR_DEFAULT_HELP_CATEGORY, COMMAND_FUNC_PREFIX, @@ -651,49 +656,6 @@ def _(event: Any) -> None: # pragma: no cover # completion results when self.matches_sorted is False self.default_sort_key: Callable[[str], str] = Cmd.ALPHABETICAL_SORT_KEY - ############################################################################################################ - # The following variables are used by completion functions. They are reset each time complete() is run - # in _reset_completion_defaults() and it is up to completer functions to set them before returning results. - ############################################################################################################ - - # If True and a single match is returned to complete(), then a space will be appended - # if the match appears at the end of the line - self.allow_appended_space = True - - # If True and a single match is returned to complete(), then a closing quote - # will be added if there is an unmatched opening quote - self.allow_closing_quote = True - - # An optional hint which prints above completion suggestions - self.completion_hint: str = '' - - # Normally cmd2 uses prompt-toolkit's formatter to columnize the list of completion suggestions. - # If a custom format is preferred, write the formatted completions to this string. cmd2 will - # then print it instead of the prompt-toolkit format. ANSI style sequences and newlines are supported - # when using this value. Even when using formatted_completions, the full matches must still be returned - # from your completer function. ArgparseCompleter writes its completion tables to this string. - self.formatted_completions: str = '' - - # Used by complete() for prompt-toolkit completion - self.completion_matches: list[str] = [] - - # Use this list if you need to display completion suggestions that are different than the actual text - # of the matches. For instance, if you are completing strings that contain a common delimiter and you only - # want to display the final portion of the matches as the completion suggestions. The full matches - # still must be returned from your completer function. For an example, look at path_complete() which - # uses this to show only the basename of paths as the suggestions. delimiter_complete() also populates - # this list. These are ignored if self.formatted_completions is populated. - self.display_matches: list[str] = [] - - # Used by functions like path_complete() and delimiter_complete() to properly - # quote matches that are completed in a delimited fashion - self.matches_delimited = False - - # Set to True before returning matches to complete() in cases where matches have already been sorted. - # If False, then complete() will sort the matches using self.default_sort_key before they are displayed. - # This does not affect self.formatted_completions. - self.matches_sorted: bool = False - # Command parsers for this Cmd instance. self._command_parsers: _CommandParsers = _CommandParsers(self) @@ -1484,11 +1446,61 @@ def pwarning( rich_print_kwargs=rich_print_kwargs, ) + def format_exception(self, exception: BaseException) -> str: + """Format an exception for printing. + + If `debug` is true, a full traceback is included, if one exists. + + :param exception: the exception to be printed. + :return: a formatted exception string + """ + console = Cmd2ExceptionConsole() + with console.capture() as capture: + # Only print a traceback if we're in debug mode and one exists. + if self.debug and sys.exc_info() != (None, None, None): + traceback = Traceback( + width=None, # Use all available width + code_width=None, # Use all available width + show_locals=True, + max_frames=0, # 0 means full traceback. + word_wrap=True, # Wrap long lines of code instead of truncate + ) + console.print(traceback) + + else: + # Print the exception in the same style Rich uses after a traceback. + exception_str = str(exception) + + if exception_str: + highlighter = ReprHighlighter() + + final_msg = Text.assemble( + (f"{type(exception).__name__}: ", "traceback.exc_type"), + highlighter(exception_str), + ) + else: + final_msg = Text(f"{type(exception).__name__}", style="traceback.exc_type") + + # If not in debug mode and the 'debug' setting is available, + # inform the user how to enable full tracebacks. + if not self.debug and 'debug' in self.settables: + help_msg = Text.assemble( + "\n\n", + ("To enable full traceback, run the following command: ", Cmd2Style.WARNING), + ("set debug true", Cmd2Style.COMMAND_LINE), + ) + final_msg.append(help_msg) + + console.print(final_msg) + + # Add a blank line + console.print() + + return capture.get() + def pexcept( self, exception: BaseException, - *, - console: Console | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: """Print an exception to sys.stderr. @@ -1496,52 +1508,11 @@ def pexcept( If `debug` is true, a full traceback is also printed, if one exists. :param exception: the exception to be printed. - :param console: optional Rich console to use for printing. If None, a new Cmd2ExceptionConsole - instance is created which writes to sys.stderr. :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this method and still call `super()` without encountering unexpected keyword argument errors. """ - if console is None: - console = Cmd2ExceptionConsole(sys.stderr) - - # Only print a traceback if we're in debug mode and one exists. - if self.debug and sys.exc_info() != (None, None, None): - traceback = Traceback( - width=None, # Use all available width - code_width=None, # Use all available width - show_locals=True, - max_frames=0, # 0 means full traceback. - word_wrap=True, # Wrap long lines of code instead of truncate - ) - console.print(traceback) - console.print() - return - - # Print the exception in the same style Rich uses after a traceback. - exception_str = str(exception) - - if exception_str: - highlighter = ReprHighlighter() - - final_msg = Text.assemble( - (f"{type(exception).__name__}: ", "traceback.exc_type"), - highlighter(exception_str), - ) - else: - final_msg = Text(f"{type(exception).__name__}", style="traceback.exc_type") - - # If not in debug mode and the 'debug' setting is available, - # inform the user how to enable full tracebacks. - if not self.debug and 'debug' in self.settables: - help_msg = Text.assemble( - "\n\n", - ("To enable full traceback, run the following command: ", Cmd2Style.WARNING), - ("set debug true", Cmd2Style.COMMAND_LINE), - ) - final_msg.append(help_msg) - - console.print(final_msg) - console.print() + formatted_exception = self.format_exception(exception) + self.print_to(sys.stderr, formatted_exception) def pfeedback( self, @@ -1707,23 +1678,6 @@ def ppaged( rich_print_kwargs=rich_print_kwargs, ) - # ----- Methods related to completion ----- - - def _reset_completion_defaults(self) -> None: - """Reset completion settings. - - Needs to be called each time prompt-toolkit runs completion. - """ - self.allow_appended_space = True - self.allow_closing_quote = True - self.completion_hint = '' - self.formatted_completions = '' - self.completion_matches = [] - self.display_matches = [] - self.completion_header = '' - self.matches_delimited = False - self.matches_sorted = False - def get_bottom_toolbar(self) -> list[str | tuple[str, str]] | None: """Get the bottom toolbar content. @@ -1840,7 +1794,7 @@ def basic_complete( begidx: int, # noqa: ARG002 endidx: int, # noqa: ARG002 match_against: Iterable[str], - ) -> list[str]: + ) -> Completions: """Completion function that matches against a list of strings without considering line contents or cursor position. The args required by this function are defined in the header of Python's cmd.py. @@ -1850,9 +1804,10 @@ def basic_complete( :param begidx: the beginning index of the prefix text :param endidx: the ending index of the prefix text :param match_against: the strings being matched against - :return: a list of possible completions + :return: a Completions object """ - return [cur_match for cur_match in match_against if cur_match.startswith(text)] + matches = [cur_match for cur_match in match_against if cur_match.startswith(text)] + return Completions(matches) def delimiter_complete( self, @@ -1862,7 +1817,7 @@ def delimiter_complete( endidx: int, match_against: Iterable[str], delimiter: str, - ) -> list[str]: + ) -> Completions: """Perform completion against a list but each match is split on a delimiter. Only the portion of the match being completed is shown as the completion suggestions. @@ -1893,29 +1848,29 @@ def delimiter_complete( :param endidx: the ending index of the prefix text :param match_against: the list being matched against :param delimiter: what delimits each portion of the matches (ex: paths are delimited by a slash) - :return: a list of possible completions + :return: a Completions object """ - matches = self.basic_complete(text, line, begidx, endidx, match_against) - if not matches: - return [] + basic_completions = self.basic_complete(text, line, begidx, endidx, match_against) + if not basic_completions.matches: + return Completions() + + completions = Completions() # Set this to True for proper quoting of matches with spaces - self.matches_delimited = True + completions.matches_delimited = True # Get the common beginning for the matches - common_prefix = os.path.commonprefix(matches) - prefix_tokens = common_prefix.split(delimiter) + common_prefix = os.path.commonprefix(completions.matches) # Calculate what portion of the match we are completing - display_token_index = 0 - if prefix_tokens: - display_token_index = len(prefix_tokens) - 1 + prefix_tokens = common_prefix.split(delimiter) + display_token_index = len(prefix_tokens) - 1 # Remove from each match everything after where the user is completing. # This approach can result in duplicates so we will filter those out. unique_results: dict[str, str] = {} - for cur_match in matches: + for cur_match in completions.matches: match_tokens = cur_match.split(delimiter) filtered_match = delimiter.join(match_tokens[: display_token_index + 1]) @@ -1925,16 +1880,16 @@ def delimiter_complete( if len(match_tokens) > display_token_index + 1: filtered_match += delimiter display_match += delimiter - self.allow_appended_space = False - self.allow_closing_quote = False + completions.allow_appended_space = False + completions.allow_closing_quote = False if filtered_match not in unique_results: unique_results[filtered_match] = display_match - filtered_matches = list(unique_results.keys()) - self.display_matches = list(unique_results.values()) + completions.matches = list(unique_results.keys()) + completions.display_matches = list(unique_results.values()) - return filtered_matches + return completions def flag_based_complete( self, @@ -1945,7 +1900,7 @@ def flag_based_complete( flag_dict: dict[str, Iterable[str] | CompleterFunc], *, all_else: None | Iterable[str] | CompleterFunc = None, - ) -> list[str]: + ) -> Completions: """Completes based on a particular flag preceding the token being completed. :param text: the string prefix we are attempting to match (all matches must begin with it) @@ -1959,14 +1914,13 @@ def flag_based_complete( 1. iterable list of strings to match against (dictionaries, lists, etc.) 2. function that performs completion (ex: path_complete) :param all_else: an optional parameter for completing any token that isn't preceded by a flag in flag_dict - :return: a list of possible completions + :return: a Completions object """ # Get all tokens through the one being completed tokens, _ = self.tokens_for_completion(line, begidx, endidx) if not tokens: # pragma: no cover - return [] + return Completions() - completions_matches = [] match_against = all_else # Must have at least 2 args for a flag to precede the token being completed @@ -1977,13 +1931,13 @@ def flag_based_complete( # Perform completion using an Iterable if isinstance(match_against, Iterable): - completions_matches = self.basic_complete(text, line, begidx, endidx, match_against) + return self.basic_complete(text, line, begidx, endidx, match_against) # Perform completion using a function - elif callable(match_against): - completions_matches = match_against(text, line, begidx, endidx) + if callable(match_against): + return match_against(text, line, begidx, endidx) - return completions_matches + return Completions() def index_based_complete( self, @@ -1994,7 +1948,7 @@ def index_based_complete( index_dict: Mapping[int, Iterable[str] | CompleterFunc], *, all_else: Iterable[str] | CompleterFunc | None = None, - ) -> list[str]: + ) -> Completions: """Completes based on a fixed position in the input string. :param text: the string prefix we are attempting to match (all matches must begin with it) @@ -2008,14 +1962,12 @@ def index_based_complete( 1. iterable list of strings to match against (dictionaries, lists, etc.) 2. function that performs completion (ex: path_complete) :param all_else: an optional parameter for completing any token that isn't at an index in index_dict - :return: a list of possible completions + :return: a Completions object """ # Get all tokens through the one being completed tokens, _ = self.tokens_for_completion(line, begidx, endidx) if not tokens: # pragma: no cover - return [] - - matches = [] + return Completions() # Get the index of the token being completed index = len(tokens) - 1 @@ -2026,13 +1978,55 @@ def index_based_complete( # Perform completion using a Iterable if isinstance(match_against, Iterable): - matches = self.basic_complete(text, line, begidx, endidx, match_against) + return self.basic_complete(text, line, begidx, endidx, match_against) # Perform completion using a function - elif callable(match_against): - matches = match_against(text, line, begidx, endidx) + if callable(match_against): + return match_against(text, line, begidx, endidx) - return matches + return Completions() + + @staticmethod + def _complete_users(text: str, add_trailing_sep_if_dir: bool) -> Completions: + """Complete ~ and ~user strings. + + :param text: the string prefix we are attempting to match (all matches must begin with it) + :param add_trailing_sep_if_dir: whether a trailing separator should be appended to directory completions + :return: a Completions object + """ + completions = Completions() + + # Windows lacks the pwd module so we can't get a list of users. + # Instead we will return a result once the user enters text that + # resolves to an existing home directory. + if sys.platform.startswith('win'): + expanded_path = os.path.expanduser(text) + if os.path.isdir(expanded_path): + user = text + if add_trailing_sep_if_dir: + user += os.path.sep + completions.matches.append(user) + else: + import pwd + + # Iterate through a list of users from the password database + for cur_pw in pwd.getpwall(): + # Check if the user has an existing home dir + if os.path.isdir(cur_pw.pw_dir): + # Add a ~ to the user to match against text + cur_user = '~' + cur_pw.pw_name + if cur_user.startswith(text): + if add_trailing_sep_if_dir: + cur_user += os.path.sep + completions.matches.append(cur_user) + + if completions: + # We are returning ~user strings that resolve to directories, + # so don't append a space or quote in the case of a single result. + completions.allow_appended_space = False + completions.allow_closing_quote = False + + return completions def path_complete( self, @@ -2042,7 +2036,7 @@ def path_complete( endidx: int, *, path_filter: Callable[[str], bool] | None = None, - ) -> list[str]: + ) -> Completions: """Perform completion of local file system paths. :param text: the string prefix we are attempting to match (all matches must begin with it) @@ -2052,45 +2046,8 @@ def path_complete( :param path_filter: optional filter function that determines if a path belongs in the results this function takes a path as its argument and returns True if the path should be kept in the results - :return: a list of possible completions + :return: a Completions object """ - - # Used to complete ~ and ~user strings - def complete_users() -> list[str]: - users = [] - - # Windows lacks the pwd module so we can't get a list of users. - # Instead we will return a result once the user enters text that - # resolves to an existing home directory. - if sys.platform.startswith('win'): - expanded_path = os.path.expanduser(text) - if os.path.isdir(expanded_path): - user = text - if add_trailing_sep_if_dir: - user += os.path.sep - users.append(user) - else: - import pwd - - # Iterate through a list of users from the password database - for cur_pw in pwd.getpwall(): - # Check if the user has an existing home dir - if os.path.isdir(cur_pw.pw_dir): - # Add a ~ to the user to match against text - cur_user = '~' + cur_pw.pw_name - if cur_user.startswith(text): - if add_trailing_sep_if_dir: - cur_user += os.path.sep - users.append(cur_user) - - if users: - # We are returning ~user strings that resolve to directories, - # so don't append a space or quote in the case of a single result. - self.allow_appended_space = False - self.allow_closing_quote = False - - return users - # Determine if a trailing separator should be appended to directory completions add_trailing_sep_if_dir = False if endidx == len(line) or (endidx < len(line) and line[endidx] != os.path.sep): @@ -2113,7 +2070,7 @@ def complete_users() -> list[str]: wildcards = ['*', '?'] for wildcard in wildcards: if wildcard in text: - return [] + return Completions() # Start the search string search_str = text + '*' @@ -2124,7 +2081,7 @@ def complete_users() -> list[str]: # If there is no slash, then the user is still completing the user after the tilde if sep_index == -1: - return complete_users() + return self._complete_users(text, add_trailing_sep_if_dir) # Otherwise expand the user dir search_str = os.path.expanduser(search_str) @@ -2139,47 +2096,52 @@ def complete_users() -> list[str]: cwd_added = True # Find all matching path completions - matches = glob.glob(search_str) + completions = Completions() + completions.matches = glob.glob(search_str) # Filter out results that don't belong if path_filter is not None: - matches = [c for c in matches if path_filter(c)] + completions.matches = [c for c in completions.matches if path_filter(c)] - if matches: + if completions: # Set this to True for proper quoting of paths with spaces - self.matches_delimited = True + completions.matches_delimited = True # Don't append a space or closing quote to directory - if len(matches) == 1 and os.path.isdir(matches[0]): - self.allow_appended_space = False - self.allow_closing_quote = False + if len(completions) == 1 and os.path.isdir(completions.matches[0]): + completions.allow_appended_space = False + completions.allow_closing_quote = False # Sort the matches before any trailing slashes are added - matches.sort(key=self.default_sort_key) - self.matches_sorted = True + completions.matches.sort(key=self.default_sort_key) + completions.matches_sorted = True # Build display_matches and add a slash to directories - for index, cur_match in enumerate(matches): + for index, cur_match in enumerate(completions.matches): # Display only the basename of this path in the completion suggestions - self.display_matches.append(os.path.basename(cur_match)) + completions.display_matches.append(os.path.basename(cur_match)) # Add a separator after directories if the next character isn't already a separator if os.path.isdir(cur_match) and add_trailing_sep_if_dir: - matches[index] += os.path.sep - self.display_matches[index] += os.path.sep + completions.matches[index] += os.path.sep + completions.display_matches[index] += os.path.sep # Remove cwd if it was added to match the text prompt-toolkit expects if cwd_added: to_replace = cwd if cwd == os.path.sep else cwd + os.path.sep - matches = [cur_path.replace(to_replace, '', 1) for cur_path in matches] + completions.matches = [cur_path.replace(to_replace, '', 1) for cur_path in completions.matches] # Restore the tilde string if we expanded one to match the text prompt-toolkit expects if expanded_tilde_path: - matches = [cur_path.replace(expanded_tilde_path, orig_tilde_path, 1) for cur_path in matches] + completions.matches = [ + cur_path.replace(expanded_tilde_path, orig_tilde_path, 1) for cur_path in completions.matches + ] - return matches + return completions - def shell_cmd_complete(self, text: str, line: str, begidx: int, endidx: int, *, complete_blank: bool = False) -> list[str]: + def shell_cmd_complete( + self, text: str, line: str, begidx: int, endidx: int, *, complete_blank: bool = False + ) -> Completions: """Perform completion of executables either in a user's path or a given path. :param text: the string prefix we are attempting to match (all matches must begin with it) @@ -2188,22 +2150,23 @@ def shell_cmd_complete(self, text: str, line: str, begidx: int, endidx: int, *, :param endidx: the ending index of the prefix text :param complete_blank: If True, then a blank will complete all shell commands in a user's path. If False, then no completion is performed. Defaults to False to match Bash shell behavior. - :return: a list of possible completions + :return: a Completions object """ # Don't complete anything if no shell command has been started if not complete_blank and not text: - return [] + return Completions() # If there are no path characters in the search text, then do shell command completion in the user's path if not text.startswith('~') and os.path.sep not in text: - return utils.get_exes_in_path(text) + exes = utils.get_exes_in_path(text) + return Completions(exes) # Otherwise look for executables in the given path return self.path_complete( text, line, begidx, endidx, path_filter=lambda path: os.path.isdir(path) or os.access(path, os.X_OK) ) - def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, compfunc: CompleterFunc) -> list[str]: + def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, compfunc: CompleterFunc) -> Completions: """First completion function for all commands, called by complete(). It determines if it should complete for redirection (|, >, >>) or use the @@ -2215,13 +2178,13 @@ def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, com :param endidx: the ending index of the prefix text :param compfunc: the completer function for the current command this will be called if we aren't completing for redirection - :return: a list of possible completions + :return: a Completions object """ # Get all tokens through the one being completed. We want the raw tokens # so we can tell if redirection strings are quoted and ignore them. _, raw_tokens = self.tokens_for_completion(line, begidx, endidx) if not raw_tokens: # pragma: no cover - return [] + return Completions() # Must at least have the command if len(raw_tokens) > 1: @@ -2244,7 +2207,7 @@ def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, com if cur_token == constants.REDIRECTION_PIPE: # Do not complete bad syntax (e.g cmd | |) if prior_token == constants.REDIRECTION_PIPE: - return [] + return Completions() in_pipe = True in_file_redir = False @@ -2253,7 +2216,7 @@ def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, com else: if prior_token in constants.REDIRECTION_TOKENS or in_file_redir: # Do not complete bad syntax (e.g cmd | >) (e.g cmd > blah >) - return [] + return Completions() in_pipe = False in_file_redir = True @@ -2279,7 +2242,7 @@ def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, com # If there were redirection strings anywhere on the command line, then we # are no longer completing for the current command if has_redirection: - return [] + return Completions() # Call the command's completer function return compfunc(text, line, begidx, endidx) @@ -2301,7 +2264,7 @@ def _determine_ap_completer_type(parser: argparse.ArgumentParser) -> type[argpar def _perform_completion( self, text: str, line: str, begidx: int, endidx: int, custom_settings: utils.CustomCompletionSettings | None = None - ) -> None: + ) -> Completions: """Perform the actual completion, helper function for complete(). :param text: the string prefix we are attempting to match (all matches must begin with it) @@ -2309,6 +2272,7 @@ def _perform_completion( :param begidx: the beginning index of the prefix text :param endidx: the ending index of the prefix text :param custom_settings: optional prepopulated completion settings + :return: a Completions object """ # If custom_settings is None, then we are completing a command's argument. # Parse the command line to get the command token. @@ -2319,7 +2283,7 @@ def _perform_completion( # Malformed command line (e.g. quoted command token) if not command: - return + return Completions() expanded_line = statement.command_and_args @@ -2344,7 +2308,7 @@ def _perform_completion( # Get all tokens through the one being completed tokens, raw_tokens = self.tokens_for_completion(line, begidx, endidx) if not tokens: # pragma: no cover - return + return Completions() # Determine the completer function to use for the command's argument if custom_settings is None: @@ -2428,51 +2392,53 @@ def _perform_completion( # Attempt completion for redirection first, and if that isn't occurring, # call the completer function for the current command - self.completion_matches = self._redirect_complete(text, line, begidx, endidx, completer_func) + completions = self._redirect_complete(text, line, begidx, endidx, completer_func) - if self.completion_matches: + if completions: # Eliminate duplicates - self.completion_matches = utils.remove_duplicates(self.completion_matches) - self.display_matches = utils.remove_duplicates(self.display_matches) + completions.matches = utils.remove_duplicates(completions.matches) + completions.display_matches = utils.remove_duplicates(completions.display_matches) - if not self.display_matches: - # Since self.display_matches is empty, set it to self.completion_matches - # before we alter them. That way the suggestions will reflect how we parsed - # the token being completed and not how prompt-toolkit did. + if not completions.display_matches: + # Since display_matches is empty, set it to matches before we alter them. + # That way the suggestions will reflect how we parsed the token being completed + # and not how prompt-toolkit did. import copy - self.display_matches = copy.copy(self.completion_matches) + completions.display_matches = copy.copy(completions.matches) # Check if we need to add an opening quote if not completion_token_quote: add_quote = False # This is the completion text that will appear on the command line. - common_prefix = os.path.commonprefix(self.completion_matches) + common_prefix = os.path.commonprefix(completions.matches) - if self.matches_delimited: + if completions.matches_delimited: # For delimited matches, we check for a space in what appears before the display # matches (common_prefix) as well as in the display matches themselves. - if ' ' in common_prefix or any(' ' in match for match in self.display_matches): + if ' ' in common_prefix or any(' ' in match for match in completions.display_matches): add_quote = True # If there is a completion and any match has a space, then add an opening quote - elif any(' ' in match for match in self.completion_matches): + elif any(' ' in match for match in completions.matches): add_quote = True if add_quote: # Figure out what kind of quote to add and save it as the unclosed_quote - completion_token_quote = "'" if any('"' in match for match in self.completion_matches) else '"' + completion_token_quote = "'" if any('"' in match for match in completions.matches) else '"' - self.completion_matches = [completion_token_quote + match for match in self.completion_matches] + completions.matches = [completion_token_quote + match for match in completions.matches] # Check if we need to remove text from the beginning of completions elif text_to_remove: - self.completion_matches = [match.replace(text_to_remove, '', 1) for match in self.completion_matches] + completions.matches = [match.replace(text_to_remove, '', 1) for match in completions.matches] # If we have one result, then add a closing quote if needed and allowed - if len(self.completion_matches) == 1 and self.allow_closing_quote and completion_token_quote: - self.completion_matches[0] += completion_token_quote + if len(completions) == 1 and completions.allow_closing_quote and completion_token_quote: + completions.matches[0] += completion_token_quote + + return completions def complete( self, @@ -2481,118 +2447,90 @@ def complete( begidx: int, endidx: int, custom_settings: utils.CustomCompletionSettings | None = None, - ) -> str | None: - """Handle completion for an input line. + ) -> Completions: + """Handle completion for an input line and return a validated Completions object. :param text: the current word that user is typing :param line: current input line :param begidx: beginning index of text :param endidx: ending index of text :param custom_settings: used when not completing the main command line - :return: the next possible completion for text or None + :return: a validated Completions object + :raises CompletionError: if a completion-related exception occurs + :raises Exception: for any unhandled underlying processing errors """ - try: - self._reset_completion_defaults() - - # Check if we are completing a multiline command - if self._at_continuation_prompt: - # lstrip and prepend the previously typed portion of this multiline command - lstripped_previous = self._multiline_in_progress.lstrip() - line = lstripped_previous + line - - # Increment the indexes to account for the prepended text - begidx = len(lstripped_previous) + begidx - endidx = len(lstripped_previous) + endidx + # Check if we are completing a multiline command + if self._at_continuation_prompt: + # lstrip and prepend the previously typed portion of this multiline command + lstripped_previous = self._multiline_in_progress.lstrip() + line = lstripped_previous + line + + # Increment the indexes to account for the prepended text + begidx = len(lstripped_previous) + begidx + endidx = len(lstripped_previous) + endidx + else: + # lstrip the original line + orig_line = line + line = orig_line.lstrip() + num_stripped = len(orig_line) - len(line) + + # Calculate new indexes for the stripped line. If the cursor is at a position before the end of a + # line of spaces, then the following math could result in negative indexes. Enforce a max of 0. + begidx = max(begidx - num_stripped, 0) + endidx = max(endidx - num_stripped, 0) + + # Shortcuts are not word break characters when completing. Therefore, shortcuts become part + # of the text variable if there isn't a word break, like a space, after it. We need to remove it + # from text and update the indexes. This only applies if we are at the beginning of the command line. + shortcut_to_restore = '' + if begidx == 0 and custom_settings is None: + for shortcut, _ in self.statement_parser.shortcuts: + if text.startswith(shortcut): + # Save the shortcut to restore later + shortcut_to_restore = shortcut + + # Adjust text and where it begins + text = text[len(shortcut_to_restore) :] + begidx += len(shortcut_to_restore) + break else: - # lstrip the original line - orig_line = line - line = orig_line.lstrip() - num_stripped = len(orig_line) - len(line) - - # Calculate new indexes for the stripped line. If the cursor is at a position before the end of a - # line of spaces, then the following math could result in negative indexes. Enforce a max of 0. - begidx = max(begidx - num_stripped, 0) - endidx = max(endidx - num_stripped, 0) - - # Shortcuts are not word break characters when completing. Therefore, shortcuts become part - # of the text variable if there isn't a word break, like a space, after it. We need to remove it - # from text and update the indexes. This only applies if we are at the beginning of the command line. - shortcut_to_restore = '' - if begidx == 0 and custom_settings is None: - for shortcut, _ in self.statement_parser.shortcuts: - if text.startswith(shortcut): - # Save the shortcut to restore later - shortcut_to_restore = shortcut - - # Adjust text and where it begins - text = text[len(shortcut_to_restore) :] - begidx += len(shortcut_to_restore) - break - else: - # No shortcut was found. Complete the command token. - parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(add_help=False) - parser.add_argument( - 'command', - metavar="COMMAND", - help="command, alias, or macro name", - choices=self._get_commands_aliases_and_macros_for_completion(), - suppress_tab_hint=True, - ) - custom_settings = utils.CustomCompletionSettings(parser) + # No shortcut was found. Complete the command token. + parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(add_help=False) + parser.add_argument( + 'command', + metavar="COMMAND", + help="command, alias, or macro name", + choices=self._get_commands_aliases_and_macros_for_completion(), + suppress_tab_hint=True, + ) + custom_settings = utils.CustomCompletionSettings(parser) - self._perform_completion(text, line, begidx, endidx, custom_settings) + completions = self._perform_completion(text, line, begidx, endidx, custom_settings) - # Check if we need to restore a shortcut in the completions - # so it doesn't get erased from the command line - if shortcut_to_restore: - self.completion_matches = [shortcut_to_restore + match for match in self.completion_matches] + # Check if we need to restore a shortcut in the completions + # so it doesn't get erased from the command line + if shortcut_to_restore: + completions.matches = [shortcut_to_restore + match for match in completions.matches] - # If we have one result and we are at the end of the line, then add a space if allowed - if len(self.completion_matches) == 1 and endidx == len(line) and self.allow_appended_space: - self.completion_matches[0] += ' ' + # If we have one result and we are at the end of the line, then add a space if allowed + if len(completions) == 1 and endidx == len(line) and completions.allow_appended_space: + completions.matches[0] += ' ' - # Sort matches if they haven't already been sorted - if not self.matches_sorted: - self.completion_matches.sort(key=self.default_sort_key) - self.display_matches.sort(key=self.default_sort_key) - self.matches_sorted = True + # Sort matches if they haven't already been sorted + if not completions.matches_sorted: + completions.matches.sort(key=self.default_sort_key) + completions.display_matches.sort(key=self.default_sort_key) + completions.matches_sorted = True - # Swap between COLUMN and MULTI_COLUMN style based on the number of matches if not using READLINE_LIKE - if len(self.completion_matches) > self.max_column_completion_results: - self.session.complete_style = CompleteStyle.MULTI_COLUMN - else: - self.session.complete_style = CompleteStyle.COLUMN + # Swap between COLUMN and MULTI_COLUMN style based on the number of matches if not using READLINE_LIKE + if len(completions) > self.max_column_completion_results: + self.session.complete_style = CompleteStyle.MULTI_COLUMN + else: + self.session.complete_style = CompleteStyle.COLUMN - try: - return self.completion_matches[0] - except IndexError: - return None - - except CompletionError as ex: - # Don't print error and redraw the prompt unless the error has length - err_str = str(ex) - if err_str: - # If apply_style is True, then this is an error message that should be printed - # above the prompt so it remains in the scrollback. - if ex.apply_style: - # Render the error with style to a string using Rich - general_console = ru.Cmd2GeneralConsole() - with general_console.capture() as capture: - general_console.print("\n" + err_str, style=Cmd2Style.ERROR) - self.completion_header = capture.get() - - # Otherwise, this is a hint that should be displayed below the prompt. - else: - self.completion_hint = err_str - return None - except Exception as ex: # noqa: BLE001 - # Insert a newline so the exception doesn't print in the middle of the command line being completed - exception_console = ru.Cmd2ExceptionConsole() - with exception_console.capture() as capture: - exception_console.print() - self.pexcept(ex, console=exception_console) - self.completion_header = capture.get() - return None + # Run validation before returning + completions.validate() + return completions def in_script(self) -> bool: """Return whether a text script is running.""" @@ -3347,14 +3285,14 @@ def default(self, statement: Statement) -> bool | None: self.perror(err_msg, style=None) return None - def completedefault(self, *_ignored: list[str]) -> list[str]: + def completedefault(self, *_ignored: list[str]) -> Completions: """Call to complete an input line when no command-specific complete_*() method is available. This method is only called for non-argparse-based commands. - By default, it returns an empty list. + By default, it returns a Completions object with no matches. """ - return [] + return Completions() def _suggest_similar_command(self, command: str) -> str | None: return suggest_similar(command, self.get_visible_commands()) @@ -3397,7 +3335,6 @@ def read_input( :return: the line read from stdin with all trailing new lines removed :raises Exception: any exceptions raised by prompt() """ - self._reset_completion_defaults() with self._in_prompt_lock: self._in_prompt = True try: @@ -3755,7 +3692,7 @@ def macro_arg_complete( line: str, begidx: int, endidx: int, - ) -> list[str]: + ) -> Completions: """Completes arguments to a macro. Its default behavior is to call path_complete, but you can override this as needed. @@ -3766,7 +3703,7 @@ def macro_arg_complete( :param line: the current input line with leading whitespace removed :param begidx: the beginning index of the prefix text :param endidx: the ending index of the prefix text - :return: a list of possible completions + :return: a Completions object """ return self.path_complete(text, line, begidx, endidx) @@ -4031,7 +3968,7 @@ def _macro_list(self, args: argparse.Namespace) -> None: for name in not_found: self.perror(f"Macro '{name}' not found") - def complete_help_command(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: + def complete_help_command(self, text: str, line: str, begidx: int, endidx: int) -> Completions: """Completes the command argument of help.""" # Complete token against topics and visible commands topics = set(self.get_help_topics()) @@ -4041,16 +3978,16 @@ def complete_help_command(self, text: str, line: str, begidx: int, endidx: int) def complete_help_subcommands( self, text: str, line: str, begidx: int, endidx: int, arg_tokens: dict[str, list[str]] - ) -> list[str]: + ) -> Completions: """Completes the subcommands argument of help.""" # Make sure we have a command whose subcommands we will complete command = arg_tokens['command'][0] if not command: - return [] + return Completions() # Check if this command uses argparse if (func := self.cmd_func(command)) is None or (argparser := self._command_parsers.get(func)) is None: - return [] + return Completions() completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self) return completer.complete_subcommand_help(text, line, begidx, endidx, arg_tokens['subcommands']) @@ -4463,13 +4400,13 @@ def _build_base_set_parser(cls) -> Cmd2ArgumentParser: def complete_set_value( self, text: str, line: str, begidx: int, endidx: int, arg_tokens: dict[str, list[str]] - ) -> list[str]: + ) -> Completions: """Completes the value argument of set.""" param = arg_tokens['param'][0] try: settable = self.settables[param] - except KeyError as exc: - raise CompletionError(param + " is not a settable parameter") from exc + except KeyError as ex: + raise CompletionError(param + " is not a settable parameter") from ex # Create a parser with a value field based on this settable settable_parser = self._build_base_set_parser() diff --git a/cmd2/completion.py b/cmd2/completion.py new file mode 100644 index 000000000..b687eabfc --- /dev/null +++ b/cmd2/completion.py @@ -0,0 +1,190 @@ +"""Provides classes and functions related to completion.""" + +import sys +from collections.abc import ( + Sequence, +) +from typing import ( + Any, + Protocol, + runtime_checkable, +) + +from rich.protocol import is_renderable + +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + + +from dataclasses import ( + dataclass, + field, +) + +from . import rich_utils as ru + + +@dataclass(slots=True) +class Completions: + """The result and display configuration for a completion operation. + + Note: Validation of data integrity is performed by Cmd.complete() before returning. + """ + + # The list of completions + matches: list[str] = field(default_factory=list) + + # Optional strings for displaying the matches differently in the completion menu. + # If populated, it must be the same length as matches. + display_matches: list[str] = field(default_factory=list) + + # Optional meta information about each match which displays in the completion menu. + # If populated, it must be the same length as matches. + display_meta: list[str] = field(default_factory=list) + + # If True and a single match is returned to complete(), then a space will be appended + # if the match appears at the end of the line + allow_appended_space: bool = True + + # If True and a single match is returned to complete(), then a closing quote + # will be added if there is an unmatched opening quote + allow_closing_quote: bool = True + + # An optional hint which prints above completion suggestions + completion_hint: str = "" + + # Normally cmd2 uses prompt-toolkit's formatter to columnize the list of completion suggestions. + # If a custom format is preferred, write the formatted completions to this string. cmd2 will + # then print it instead of the prompt-toolkit format. ANSI style sequences and newlines are supported + # when using this value. Even when using formatted_completions, the full matches must still be returned + # from your completer function. ArgparseCompleter writes its completion tables to this string. + formatted_completions: str = "" + + # Used by functions like path_complete() and delimiter_complete() to properly + # quote matches that are completed in a delimited fashion + matches_delimited: bool = False + + # Set to True before returning matches to complete() in cases where matches have already been sorted. + # If False, then complete() will sort the matches using self.default_sort_key before they are displayed. + # This does not affect formatted_completions. + matches_sorted: bool = False + + def __bool__(self) -> bool: + """Return True if there are matches, False otherwise.""" + return bool(self.matches) + + def __len__(self) -> int: + """Return the number of matches.""" + return len(self.matches) + + def validate(self) -> None: + """Validate data integrity. + + :raises ValueError: if there is an issue with the data. + """ + num_matches = len(self.matches) + + # Check display_matches + if self.display_matches and len(self.display_matches) != num_matches: + raise ValueError( + f"Mismatched display_matches: expected {num_matches} items " + f"(to match 'matches'), but got {len(self.display_matches)}." + ) + + # Check display_meta + if self.display_meta and len(self.display_meta) != num_matches: + raise ValueError( + f"Mismatched display_meta: expected {num_matches} items " + f"(to match 'matches'), but got {len(self.display_meta)}." + ) + + +class CompletionItem(str): # noqa: SLOT000 + """Completion item with descriptive text attached. + + See header of this file for more information + """ + + def __new__(cls, value: object, *_args: Any, **_kwargs: Any) -> Self: + """Responsible for creating and returning a new instance, called before __init__ when an object is instantiated.""" + return super().__new__(cls, value) + + def __init__(self, value: object, descriptive_data: Sequence[Any], *args: Any) -> None: + """CompletionItem Initializer. + + :param value: the value being completed + :param descriptive_data: a list of descriptive data to display in the columns that follow + the completion value. The number of items in this list must equal + the number of descriptive headers defined for the argument. + :param args: args for str __init__ + """ + super().__init__(*args) + + # Make sure all objects are renderable by a Rich table. + renderable_data = [obj if is_renderable(obj) else str(obj) for obj in descriptive_data] + + # Convert strings containing ANSI style sequences to Rich Text objects for correct display width. + self.descriptive_data = ru.prepare_objects_for_rendering(*renderable_data) + + # Save the original value to support CompletionItems as argparse choices. + # cmd2 has patched argparse so input is compared to this value instead of the CompletionItem instance. + self._orig_value = value + + @property + def orig_value(self) -> Any: + """Read-only property for _orig_value.""" + return self._orig_value + + +@runtime_checkable +class ChoicesProviderFuncBase(Protocol): + """Function that returns a list of choices in support of completion.""" + + def __call__(self) -> list[str]: # pragma: no cover + """Enable instances to be called like functions.""" + + +@runtime_checkable +class ChoicesProviderFuncWithTokens(Protocol): + """Function that returns a list of choices in support of completion and accepts a dictionary of prior arguments.""" + + def __call__(self, *, arg_tokens: dict[str, list[str]] = {}) -> list[str]: # pragma: no cover # noqa: B006 + """Enable instances to be called like functions.""" + + +ChoicesProviderFunc = ChoicesProviderFuncBase | ChoicesProviderFuncWithTokens + + +@runtime_checkable +class CompleterFuncBase(Protocol): + """Function to support completion with the provided state of the user prompt.""" + + def __call__( + self, + text: str, + line: str, + begidx: int, + endidx: int, + ) -> Completions: # pragma: no cover + """Enable instances to be called like functions.""" + + +@runtime_checkable +class CompleterFuncWithTokens(Protocol): + """Function to support completion with the provided state of the user prompt, accepts a dictionary of prior args.""" + + def __call__( + self, + text: str, + line: str, + begidx: int, + endidx: int, + *, + arg_tokens: dict[str, list[str]] = {}, # noqa: B006 + ) -> Completions: # pragma: no cover + """Enable instances to be called like functions.""" + + +CompleterFunc = CompleterFuncBase | CompleterFuncWithTokens diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index 4efe1ebcc..0238e29f8 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -22,10 +22,12 @@ from . import ( constants, - rich_utils, utils, ) -from .argparse_custom import CompletionItem +from . import rich_utils as ru +from .completion import CompletionItem +from .exceptions import CompletionError +from .styles import Cmd2Style if TYPE_CHECKING: from .cmd2 import Cmd @@ -63,53 +65,56 @@ def get_completions(self, document: Document, _complete_event: object) -> Iterab endidx = cursor_pos text = line[begidx:endidx] - # Call cmd2's complete method - self.cmd_app.complete(text, line=line, begidx=begidx, endidx=endidx, custom_settings=self.custom_settings) - - # Print formatted completions (tables) above the prompt if present - if self.cmd_app.formatted_completions: - print_formatted_text(ANSI("\n" + self.cmd_app.formatted_completions)) - self.cmd_app.formatted_completions = "" - - # Print completion header (e.g. CompletionError) if present - if self.cmd_app.completion_header: - print_formatted_text(ANSI(self.cmd_app.completion_header)) - self.cmd_app.completion_header = "" + try: + completions = self.cmd_app.complete( + text, line=line, begidx=begidx, endidx=endidx, custom_settings=self.custom_settings + ) + except CompletionError as ex: + general_console = ru.Cmd2GeneralConsole() + with general_console.capture() as capture: + general_console.print( + Text.assemble( + "\n", + (str(ex), Cmd2Style.ERROR if ex.apply_style else ""), + ), + ) + print_formatted_text(ANSI(capture.get())) + return + except Exception as ex: # noqa: BLE001 + formatted_exception = self.cmd_app.format_exception(ex) + print_formatted_text(ANSI(formatted_exception)) + return - matches = self.cmd_app.completion_matches + # Print formatted completions if present + if completions.formatted_completions: + print_formatted_text(ANSI("\n" + completions.formatted_completions)) # Print hint if present and settings say we should - if self.cmd_app.completion_hint and (self.cmd_app.always_show_hint or not matches): - print_formatted_text(ANSI(self.cmd_app.completion_hint)) - self.cmd_app.completion_hint = "" + if completions.completion_hint and (self.cmd_app.always_show_hint or not completions.matches): + print_formatted_text(ANSI(completions.completion_hint)) - if not matches: + if not completions.matches: return - # Now we iterate over self.cmd_app.completion_matches and self.cmd_app.display_matches + # Now we iterate over completions.matches and completions.display_matches. # cmd2 separates completion matches (what is inserted) from display matches (what is shown). # prompt_toolkit Completion object takes 'text' (what is inserted) and 'display' (what is shown). - # Check if we have display matches and if they match the length of completion matches - display_matches = self.cmd_app.display_matches - use_display_matches = len(display_matches) == len(matches) + # Check if we have display matches + use_display_matches = bool(completions.display_matches) - for i, match in enumerate(matches): - display = display_matches[i] if use_display_matches else match + for i, match in enumerate(completions.matches): + display = completions.display_matches[i] if use_display_matches else match display_meta: str | ANSI | None = None if isinstance(match, CompletionItem) and match.descriptive_data: if isinstance(match.descriptive_data[0], str): display_meta = match.descriptive_data[0] elif isinstance(match.descriptive_data[0], Text): # Convert rich renderable to prompt-toolkit formatted text - display_meta = ANSI(rich_utils.rich_text_to_string(match.descriptive_data[0])) - - # prompt_toolkit replaces the word before cursor by default if we use the default Completer? - # No, we yield Completion(text, start_position=...). - # Default start_position is 0 (append). + display_meta = ANSI(ru.rich_text_to_string(match.descriptive_data[0])) + # Set offset to the start of the current word to overwrite it with the completion start_position = -len(text) - yield Completion(match, start_position=start_position, display=display, display_meta=display_meta) From ab703cb7a00b9f43c2a3fb2c35b40330c51a25b7 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 5 Feb 2026 23:29:59 -0500 Subject: [PATCH 04/30] Fixed completion hint not displaying while completing a command name. Removed extra newlines in CompletionErrors and formatted exceptions. --- cmd2/argparse_completer.py | 3 +-- cmd2/cmd2.py | 6 +----- cmd2/exceptions.py | 4 ---- cmd2/pt_utils.py | 17 ++++++++--------- 4 files changed, 10 insertions(+), 20 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 1222a1ae0..0427d5c8c 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -143,7 +143,7 @@ class _NoResultsError(CompletionError): def __init__(self, parser: argparse.ArgumentParser, arg_action: argparse.Action) -> None: """CompletionError which occurs when there are no results. - If hinting is allowed, then its message will be a hint about the argument being completed. + If hinting is allowed on this argument, then its hint text will display. :param parser: ArgumentParser instance which owns the action being completed :param arg_action: action being completed. @@ -481,7 +481,6 @@ def _handle_last_token( # If we have results, then return them if completions: - # Don't overwrite an existing hint if ( not completions.completion_hint and not isinstance(pos_arg_state.action, argparse._SubParsersAction) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 42115f0a0..be5947bed 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1465,7 +1465,7 @@ def format_exception(self, exception: BaseException) -> str: max_frames=0, # 0 means full traceback. word_wrap=True, # Wrap long lines of code instead of truncate ) - console.print(traceback) + console.print(traceback, end="") else: # Print the exception in the same style Rich uses after a traceback. @@ -1493,9 +1493,6 @@ def format_exception(self, exception: BaseException) -> str: console.print(final_msg) - # Add a blank line - console.print() - return capture.get() def pexcept( @@ -2501,7 +2498,6 @@ def complete( metavar="COMMAND", help="command, alias, or macro name", choices=self._get_commands_aliases_and_macros_for_completion(), - suppress_tab_hint=True, ) custom_settings = utils.CustomCompletionSettings(parser) diff --git a/cmd2/exceptions.py b/cmd2/exceptions.py index 4e6083802..5b25aefb1 100644 --- a/cmd2/exceptions.py +++ b/cmd2/exceptions.py @@ -27,14 +27,10 @@ class CommandSetRegistrationError(Exception): class CompletionError(Exception): """Raised during completion operations to report any sort of error you want printed. - This can also be used just to display a message, even if it's not an error. For instance, ArgparseCompleter raises - CompletionErrors to display completion hints and sets apply_style to False so hints aren't colored like error text. - Example use cases: - Reading a database to retrieve a completion data set failed - A previous command line argument that determines the data set being completed is invalid - - Completion hints """ def __init__(self, *args: Any, apply_style: bool = True) -> None: diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index 0238e29f8..07f11f7a3 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -70,15 +70,14 @@ def get_completions(self, document: Document, _complete_event: object) -> Iterab text, line=line, begidx=begidx, endidx=endidx, custom_settings=self.custom_settings ) except CompletionError as ex: - general_console = ru.Cmd2GeneralConsole() - with general_console.capture() as capture: - general_console.print( - Text.assemble( - "\n", - (str(ex), Cmd2Style.ERROR if ex.apply_style else ""), - ), - ) - print_formatted_text(ANSI(capture.get())) + # Don't print unless error has length + err_str = str(ex) + if err_str: + general_console = ru.Cmd2GeneralConsole() + with general_console.capture() as capture: + styled_err = Text(err_str, style=Cmd2Style.ERROR if ex.apply_style else "") + general_console.print(styled_err, end="") + print_formatted_text(ANSI(capture.get())) return except Exception as ex: # noqa: BLE001 formatted_exception = self.cmd_app.format_exception(ex) From 20bdbd39a49ffd0af7ea303713eb8bf05e5152cd Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 12 Feb 2026 15:16:09 -0500 Subject: [PATCH 05/30] Further refactoring of completion code to better utilize prompt-toolkit. Added display_meta field to CompletionItem. Added Choices class which is returned by choices_providers. --- cmd2/__init__.py | 2 + cmd2/argparse_completer.py | 499 ++++++++++++++++--------------- cmd2/argparse_custom.py | 116 +++---- cmd2/cmd2.py | 425 +++++++++++++------------- cmd2/command_definition.py | 7 +- cmd2/completion.py | 273 +++++++++++------ cmd2/decorators.py | 28 +- cmd2/pt_utils.py | 41 +-- cmd2/utils.py | 49 +-- docs/features/settings.md | 11 +- examples/argparse_completion.py | 2 +- tests/test_argparse_completer.py | 52 +--- 12 files changed, 785 insertions(+), 720 deletions(-) diff --git a/cmd2/__init__.py b/cmd2/__init__.py index d7f7b62f8..a4d9e9fd1 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -52,6 +52,7 @@ CustomCompletionSettings, Settable, categorize, + set_default_str_sort_key, ) __all__: list[str] = [ # noqa: RUF022 @@ -99,4 +100,5 @@ 'CompletionMode', 'CustomCompletionSettings', 'Settable', + 'set_default_str_sort_key', ] diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 0427d5c8c..1c01125ad 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -4,15 +4,14 @@ """ import argparse +import dataclasses import inspect -import numbers -from collections import ( - deque, -) +from collections import deque from collections.abc import Sequence from typing import ( IO, TYPE_CHECKING, + Any, cast, ) @@ -37,12 +36,13 @@ ChoicesProviderFuncWithTokens, CompletionItem, Completions, + all_display_numeric, ) from .exceptions import CompletionError from .styles import Cmd2Style -# If no descriptive headers are supplied, then this will be used instead -DEFAULT_DESCRIPTIVE_HEADERS: Sequence[str | Column] = ['Description'] +# If no table header is supplied, then this will be used instead +DEFAULT_TABLE_HEADER: Sequence[str | Column] = ['Description'] # Name of the choice/completer function argument that, if present, will be passed a dictionary of # command line tokens up through the token being completed mapped to their argparse destination name. @@ -98,13 +98,13 @@ class _ArgumentState: def __init__(self, arg_action: argparse.Action) -> None: self.action = arg_action - self.min: int | str - self.max: float | int | str + self.min: int + self.max: float | int self.count = 0 self.is_remainder = self.action.nargs == argparse.REMAINDER # Check if nargs is a range - nargs_range = self.action.get_nargs_range() # type: ignore[attr-defined] + nargs_range: tuple[int, int | float] | None = self.action.get_nargs_range() # type: ignore[attr-defined] if nargs_range is not None: self.min = nargs_range[0] self.max = nargs_range[1] @@ -123,8 +123,8 @@ def __init__(self, arg_action: argparse.Action) -> None: self.min = 1 self.max = INFINITY else: - self.min = self.action.nargs - self.max = self.action.nargs + self.min = cast(int, self.action.nargs) + self.max = cast(int, self.action.nargs) class _UnfinishedFlagError(CompletionError): @@ -134,7 +134,7 @@ def __init__(self, flag_arg_state: _ArgumentState) -> None: :param flag_arg_state: information about the unfinished flag action. """ arg = f'{argparse._get_action_name(flag_arg_state.action)}' - err = f'{generate_range_error(cast(int, flag_arg_state.min), cast(int | float, flag_arg_state.max))}' + err = f'{generate_range_error(flag_arg_state.min, flag_arg_state.max)}' error = f"Error: argument {arg}: {err} ({flag_arg_state.count} entered)" super().__init__(error) @@ -177,10 +177,17 @@ def __init__( parent_tokens = {} self._parent_tokens = parent_tokens - self._flags = [] # all flags in this command - self._flag_to_action = {} # maps flags to the argparse action object - self._positional_actions = [] # actions for positional arguments (by position index) - self._subcommand_action = None # this will be set if self._parser has subcommands + # All flags in this command + self._flags: list[str] = [] + + # Maps flags to the argparse action object + self._flag_to_action: dict[str, argparse.Action] = {} + + # Actions for positional arguments (by position index) + self._positional_actions: list[argparse.Action] = [] + + # This will be set if self._parser has subcommands + self._subcommand_action: argparse._SubParsersAction[argparse.ArgumentParser] | None = None # Start digging through the argparse structures. # _actions is the top level container of parameter definitions @@ -238,7 +245,7 @@ def complete( flag_arg_state: _ArgumentState | None = None # Non-reusable flags that we've parsed - used_flags: list[str] = [] + used_flags: set[str] = set() # Keeps track of arguments we've seen and any tokens they consumed consumed_arg_values: dict[str, list[str]] = {} # dict(arg_name -> list[tokens]) @@ -246,17 +253,17 @@ def complete( # Completed mutually exclusive groups completed_mutex_groups: dict[argparse._MutuallyExclusiveGroup, argparse.Action] = {} - def consume_argument(arg_state: _ArgumentState, token: str) -> None: - """Consuming token as an argument.""" + def consume_argument(arg_state: _ArgumentState, arg_token: str) -> None: + """Consume token as an argument.""" arg_state.count += 1 consumed_arg_values.setdefault(arg_state.action.dest, []) - consumed_arg_values[arg_state.action.dest].append(token) + consumed_arg_values[arg_state.action.dest].append(arg_token) ############################################################################################# # Parse all but the last token ############################################################################################# for token_index, token in enumerate(tokens[:-1]): - # Remainder handling: If we're in a positional REMAINDER arg, force all future tokens to go to that + # If we're in a positional REMAINDER arg, force all future tokens to go to that if pos_arg_state is not None and pos_arg_state.is_remainder: consume_argument(pos_arg_state, token) continue @@ -272,7 +279,11 @@ def consume_argument(arg_state: _ArgumentState, token: str) -> None: # Handle '--' which tells argparse all remaining arguments are non-flags if token == '--' and not skip_remaining_flags: # noqa: S105 # Check if there is an unfinished flag - if flag_arg_state and isinstance(flag_arg_state.min, int) and flag_arg_state.count < flag_arg_state.min: + if ( + flag_arg_state is not None + and isinstance(flag_arg_state.min, int) + and flag_arg_state.count < flag_arg_state.min + ): raise _UnfinishedFlagError(flag_arg_state) # Otherwise end the current flag @@ -280,31 +291,46 @@ def consume_argument(arg_state: _ArgumentState, token: str) -> None: skip_remaining_flags = True continue - # Flag handling: Check the format of the current token to see if it can be an argument's value + # Check if token is a flag if _looks_like_flag(token, self._parser) and not skip_remaining_flags: # Check if there is an unfinished flag - if flag_arg_state and isinstance(flag_arg_state.min, int) and flag_arg_state.count < flag_arg_state.min: + if ( + flag_arg_state is not None + and isinstance(flag_arg_state.min, int) + and flag_arg_state.count < flag_arg_state.min + ): raise _UnfinishedFlagError(flag_arg_state) # Reset flag arg state but not positional tracking because flags can be # interspersed anywhere between positionals flag_arg_state = None - action = self._flag_to_action.get(token) + action = None # Does the token match a known flag? - if action is None and self._parser.allow_abbrev: - candidates = [f for f in self._flag_to_action if f.startswith(token)] - if len(candidates) == 1: - action = self._flag_to_action[candidates[0]] - if action: + if token in self._flag_to_action: + action = self._flag_to_action[token] + elif self._parser.allow_abbrev: + candidates_flags = [flag for flag in self._flag_to_action if flag.startswith(token)] + if len(candidates_flags) == 1: + action = self._flag_to_action[candidates_flags[0]] + + if action is not None: self._update_mutex_groups(action, completed_mutex_groups, used_flags, remaining_positionals) - if isinstance(action, (argparse._AppendAction, argparse._AppendConstAction, argparse._CountAction)): - # Flags with action set to append, append_const, and count can be reused - # Therefore don't erase any tokens already consumed for this flag + if isinstance( + action, + ( + argparse._AppendAction, + argparse._AppendConstAction, + argparse._CountAction, + argparse._ExtendAction, + ), + ): + # Flags with actions set to append, append_const, count, and extend can be reused. + # Therefore don't erase any tokens already consumed for this flag. consumed_arg_values.setdefault(action.dest, []) else: # This flag is not reusable, so mark that we've seen it - used_flags.extend(action.option_strings) + used_flags.update(action.option_strings) # It's possible we already have consumed values for this flag if it was used # earlier in the command line. Reset them now for this use of it. @@ -313,19 +339,19 @@ def consume_argument(arg_state: _ArgumentState, token: str) -> None: new_arg_state = _ArgumentState(action) # Keep track of this flag if it can receive arguments - if cast(float, new_arg_state.max) > 0: + if new_arg_state.max > 0: flag_arg_state = new_arg_state skip_remaining_flags = flag_arg_state.is_remainder - # Check if we are consuming a flag + # Check if token is a flag's argument elif flag_arg_state is not None: consume_argument(flag_arg_state, token) # Check if we have finished with this flag - if flag_arg_state.count >= cast(float, flag_arg_state.max): + if flag_arg_state.count >= flag_arg_state.max: flag_arg_state = None - # Positional handling: Otherwise treat as a positional argument + # Otherwise treat token as a positional argument else: # If we aren't current tracking a positional, then get the next positional arg to handle this token if pos_arg_state is None and remaining_positionals: @@ -362,7 +388,7 @@ def consume_argument(arg_state: _ArgumentState, token: str) -> None: skip_remaining_flags = True # Check if we have finished with this positional - elif pos_arg_state.count >= cast(float, pos_arg_state.max): + elif pos_arg_state.count >= pos_arg_state.max: pos_arg_state = None # Check if the next positional has nargs set to argparse.REMAINDER. @@ -391,27 +417,46 @@ def _update_mutex_groups( self, arg_action: argparse.Action, completed_mutex_groups: dict[argparse._MutuallyExclusiveGroup, argparse.Action], - used_flags: list[str], + used_flags: set[str], remaining_positionals: deque[argparse.Action], ) -> None: - """Update mutex groups state.""" + """Manage mutually exclusive group constraints and argument pruning for a given action. + + If an action belongs to a mutually exclusive group, this method ensures no other member + has been used and updates the parser state to "consume" all remaining conflicting arguments. + + :raises CompletionError: if another member of the same mutually exclusive group + has already been used. + """ + # Check if this action is in a mutually exclusive group for group in self._parser._mutually_exclusive_groups: if arg_action in group._group_actions: + # Check if the group this action belongs to has already been completed if group in completed_mutex_groups: + # If this is the action that completed the group, then there is no error + # since it's allowed to appear on the command line more than once. completer_action = completed_mutex_groups[group] - if arg_action != completer_action: - arg_str = f'{argparse._get_action_name(arg_action)}' - completer_str = f'{argparse._get_action_name(completer_action)}' - raise CompletionError(f"Error: argument {arg_str}: not allowed with argument {completer_str}") - return + if arg_action == completer_action: + return + + arg_str = f'{argparse._get_action_name(arg_action)}' + completer_str = f'{argparse._get_action_name(completer_action)}' + error = f"Error: argument {arg_str}: not allowed with argument {completer_str}" + raise CompletionError(error) + + # Mark that this action completed the group completed_mutex_groups[group] = arg_action + + # Don't complete any of the other args in the group for group_action in group._group_actions: if group_action == arg_action: continue if group_action in self._flag_to_action.values(): - used_flags.extend(group_action.option_strings) + used_flags.update(group_action.option_strings) elif group_action in remaining_positionals: remaining_positionals.remove(group_action) + + # Arg can only be in one group, so we are done break def _handle_last_token( @@ -424,7 +469,7 @@ def _handle_last_token( pos_arg_state: _ArgumentState | None, remaining_positionals: deque[argparse.Action], consumed_arg_values: dict[str, list[str]], - used_flags: list[str], + used_flags: set[str], skip_remaining_flags: bool, cmd_set: CommandSet | None, ) -> Completions: @@ -434,7 +479,11 @@ def _handle_last_token( # the current argument. We will handle the completion of flags that start with only one prefix # character (-f) at the end. if _looks_like_flag(text, self._parser) and not skip_remaining_flags: - if flag_arg_state and isinstance(flag_arg_state.min, int) and flag_arg_state.count < flag_arg_state.min: + if ( + flag_arg_state is not None + and isinstance(flag_arg_state.min, int) + and flag_arg_state.count < flag_arg_state.min + ): raise _UnfinishedFlagError(flag_arg_state) return self._complete_flags(text, line, begidx, endidx, used_flags) @@ -445,7 +494,12 @@ def _handle_last_token( # If we have results, then return them if completions: if not completions.completion_hint: - completions.completion_hint = _build_hint(self._parser, flag_arg_state.action) + # Add a hint even though there are results in case Cmd.always_show_hint is True. + completions = dataclasses.replace( + completions, + completion_hint=_build_hint(self._parser, flag_arg_state.action), + ) + return completions # Otherwise, print a hint if the flag isn't finished or text isn't possibly the start of a flag @@ -455,39 +509,24 @@ def _handle_last_token( or skip_remaining_flags ): raise _NoResultsError(self._parser, flag_arg_state.action) - return Completions() # Otherwise check if we have a positional to complete - if pos_arg_state is None and remaining_positionals: - pos_arg_state = _ArgumentState(remaining_positionals.popleft()) + elif pos_arg_state is not None or remaining_positionals: + # If we aren't current tracking a positional, then get the next positional arg to handle this token + if pos_arg_state is None: + action = remaining_positionals.popleft() + pos_arg_state = _ArgumentState(action) - if pos_arg_state is not None: completions = self._complete_arg(text, line, begidx, endidx, pos_arg_state, consumed_arg_values, cmd_set=cmd_set) - # Fallback to flags if allowed - if not skip_remaining_flags: - if _looks_like_flag(text, self._parser) or _single_prefix_char(text, self._parser): - flag_completions = self._complete_flags(text, line, begidx, endidx, used_flags) - completions.matches.extend(flag_completions.matches) - completions.display_matches.extend(flag_completions.display_matches) - elif ( - not text - and not completions - and (isinstance(pos_arg_state.max, int) and pos_arg_state.count >= pos_arg_state.max) - ): - flag_completions = self._complete_flags(text, line, begidx, endidx, used_flags) - if flag_completions: - return flag_completions - # If we have results, then return them if completions: - if ( - not completions.completion_hint - and not isinstance(pos_arg_state.action, argparse._SubParsersAction) - and not _looks_like_flag(text, self._parser) - and not _single_prefix_char(text, self._parser) - ): - completions.completion_hint = _build_hint(self._parser, pos_arg_state.action) + if not completions.completion_hint: + # Add a hint even though there are results in case Cmd.always_show_hint is True. + completions = dataclasses.replace( + completions, + completion_hint=_build_hint(self._parser, pos_arg_state.action), + ) return completions # Otherwise, print a hint if text isn't possibly the start of a flag @@ -497,14 +536,12 @@ def _handle_last_token( # If we aren't skipping remaining flags, then complete flag names if either is True: # 1. text is a single flag prefix character that didn't complete against any argument values # 2. there are no more positionals to complete - if not skip_remaining_flags and (not text or _single_prefix_char(text, self._parser) or not remaining_positionals): - # Reset any completion settings that may have been set by functions which actually had no matches. - # Otherwise, those settings could alter how the flags are displayed. + if not skip_remaining_flags and (_single_prefix_char(text, self._parser) or not remaining_positionals): return self._complete_flags(text, line, begidx, endidx, used_flags) return Completions() - def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, used_flags: list[str]) -> Completions: + def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, used_flags: set[str]) -> Completions: """Completion routine for a parsers unused flags.""" # Build a list of flags that can be completed match_against: list[str] = [] @@ -517,16 +554,16 @@ def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, used_f if action.help != argparse.SUPPRESS: match_against.append(flag) - matched_flags = self._cmd2_app.basic_complete(text, line, begidx, endidx, match_against).matches - # Build a dictionary linking actions with their matched flag names + matched_flags = self._cmd2_app.basic_complete(text, line, begidx, endidx, match_against) matched_actions: dict[argparse.Action, list[str]] = {} - for flag in matched_flags: - action = self._flag_to_action[flag] + + for item in matched_flags.items: + action = self._flag_to_action[item.text] matched_actions.setdefault(action, []).append(flag) # For completion suggestions, group matched flags by action - completions = Completions() + items: list[CompletionItem] = [] for action, option_strings in matched_actions.items(): flag_text = ', '.join(option_strings) @@ -535,73 +572,65 @@ def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, used_f flag_text = '[' + flag_text + ']' # Use the first option string as the completion result for this action - completions.matches.append(CompletionItem(option_strings[0], [action.help or ''])) - completions.display_matches.append(flag_text) - - return completions - - def _prepare_formatted_exceptions(self, arg_state: _ArgumentState, completions: Completions) -> None: - """Format CompletionItems into hint table. - - This method modifies the completions object in-place. - - :param completions: the object to modify by populating its formatted_exceptions - """ - # Nothing to do if we don't have at least 2 completions which are all CompletionItems - if len(completions) < 2 or not all(isinstance(c, CompletionItem) for c in completions.matches): - return - - completion_items = cast(list[CompletionItem], completions.matches) + items.append( + CompletionItem( + option_strings[0], + display=flag_text, + display_meta=action.help or '', + ) + ) + + return Completions(items) + + def _format_completions(self, arg_state: _ArgumentState, completions: Completions) -> Completions: + """Format CompletionItems into hint table.""" + # Skip table generation for single results or if the list exceeds the + # user-defined threshold for table display. + if len(completions) < 2 or len(completions) > self._cmd2_app.max_completion_table_items: + return completions + + # Ensure every item provides table metadata to avoid an incomplete table. + if not all(item.table_row for item in completions): + return completions + + # If a metavar was defined, use that instead of the dest field + destination = arg_state.action.metavar or arg_state.action.dest + + # Handle case where metavar was a tuple + if isinstance(destination, tuple): + # Figure out what string in the tuple to use based on how many of the arguments have been completed. + # Use min() to avoid going passed the end of the tuple to support nargs being ZERO_OR_MORE and + # ONE_OR_MORE. In those cases, argparse limits metavar tuple to 2 elements but we may be completing + # the 3rd or more argument here. + destination = destination[min(len(destination) - 1, arg_state.count)] + + # Determine if all display values are numeric so we can right-align them + all_nums = all_display_numeric(completions.items) + + # Build header row for the hint table + rich_columns: list[Column] = [] + rich_columns.append(Column(destination.upper(), justify="right" if all_nums else "left", no_wrap=True)) + table_header = cast(Sequence[str | Column] | None, arg_state.action.get_table_header()) # type: ignore[attr-defined] + if table_header is None: + table_header = DEFAULT_TABLE_HEADER + rich_columns.extend( + column if isinstance(column, Column) else Column(column, overflow="fold") for column in table_header + ) - # Check if the data being completed have a numerical type - all_nums = all(isinstance(c.orig_value, numbers.Number) for c in completion_items) + # Build the hint table + hint_table = Table(*rich_columns, box=SIMPLE_HEAD, show_edge=False, border_style=Cmd2Style.TABLE_BORDER) + for item in completions: + hint_table.add_row(item.display, *item.table_row) - # Sort CompletionItems before building the hint table - if not completions.matches_sorted: - # If all orig_value types are numbers, then sort by that value - if all_nums: - completion_items.sort(key=lambda c: c.orig_value) - # Otherwise sort as strings - else: - completion_items.sort(key=self._cmd2_app.default_sort_key) - completions.matches_sorted = True - - # Check if there are too many CompletionItems to display as a table - if len(completions) <= self._cmd2_app.max_completion_items: - if isinstance(arg_state.action, argparse._SubParsersAction) or ( - arg_state.action.metavar == "COMMAND" and arg_state.action.dest == "command" - ): - return + # Generate the hint table string + console = Cmd2GeneralConsole() + with console.capture() as capture: + console.print(hint_table, end="", soft_wrap=False) - # If a metavar was defined, use that instead of the dest field - destination = arg_state.action.metavar or arg_state.action.dest - - # Handle case where metavar was a tuple - if isinstance(destination, tuple): - # Figure out what string in the tuple to use based on how many of the arguments have been completed. - # Use min() to avoid going passed the end of the tuple to support nargs being ZERO_OR_MORE and - # ONE_OR_MORE. In those cases, argparse limits metavar tuple to 2 elements but we may be completing - # the 3rd or more argument here. - destination = destination[min(len(destination) - 1, arg_state.count)] - - # Build all headers for the hint table - headers: list[Column] = [] - headers.append(Column(destination.upper(), justify="right" if all_nums else "left", no_wrap=True)) - desc_headers = cast(Sequence[str | Column] | None, arg_state.action.get_descriptive_headers()) # type: ignore[attr-defined] - if desc_headers is None: - desc_headers = DEFAULT_DESCRIPTIVE_HEADERS - headers.extend(dh if isinstance(dh, Column) else Column(dh, overflow="fold") for dh in desc_headers) - - # Build the hint table - hint_table = Table(*headers, box=SIMPLE_HEAD, show_edge=False, border_style=Cmd2Style.TABLE_BORDER) - for item in completion_items: - hint_table.add_row(item, *item.descriptive_data) - - # Generate the hint table string - console = Cmd2GeneralConsole() - with console.capture() as capture: - console.print(hint_table, end="", soft_wrap=False) - completions.formatted_completions = capture.get() + return dataclasses.replace( + completions, + completion_table=capture.get(), + ) def complete_subcommand_help(self, text: str, line: str, begidx: int, endidx: int, tokens: list[str]) -> Completions: """Supports cmd2's help command in the completion of subcommand names. @@ -619,7 +648,8 @@ def complete_subcommand_help(self, text: str, line: str, begidx: int, endidx: in for token_index, token in enumerate(tokens): if token in self._subcommand_action.choices: parser = self._subcommand_action.choices[token] - completer = self._cmd2_app._determine_ap_completer_type(parser)(parser, self._cmd2_app) + completer_type = self._cmd2_app._determine_ap_completer_type(parser) + completer = completer_type(parser, self._cmd2_app) return completer.complete_subcommand_help(text, line, begidx, endidx, tokens[token_index + 1 :]) if token_index == len(tokens) - 1: @@ -638,126 +668,113 @@ def print_help(self, tokens: list[str], file: IO[str] | None = None) -> None: # If our parser has subcommands, we must examine the tokens and check if they are subcommands. # If so, we will let the subcommand's parser handle the rest of the tokens via another ArgparseCompleter. if tokens and self._subcommand_action is not None: - parser = cast(argparse.ArgumentParser | None, self._subcommand_action.choices.get(tokens[0])) - if parser: - completer = self._cmd2_app._determine_ap_completer_type(parser)(parser, self._cmd2_app) + parser = self._subcommand_action.choices.get(tokens[0]) + if parser is not None: + completer_type = self._cmd2_app._determine_ap_completer_type(parser) + completer = completer_type(parser, self._cmd2_app) completer.print_help(tokens[1:]) return self._parser.print_help(file=file) - def _complete_arg( - self, - text: str, - line: str, - begidx: int, - endidx: int, - arg_state: _ArgumentState, - consumed_arg_values: dict[str, list[str]], - *, - cmd_set: CommandSet | None = None, - ) -> Completions: - """Completion routine for an argparse argument. - - :return: a Completions object - :raises CompletionError: if the completer or choices function this calls raises one - """ - # Check if the arg provides choices to the user - choices_sorted = False - arg_choices: list[str] | list[CompletionItem] | ChoicesCallable + def _get_raw_choices(self, arg_state: _ArgumentState) -> list[CompletionItem] | ChoicesCallable | None: + """Extract choices from action or return the choices_callable.""" if arg_state.action.choices is not None: + # If choices are subcommands, then get their help text to populate display_meta. if isinstance(arg_state.action, argparse._SubParsersAction): - items: list[CompletionItem] = [] parser_help = {} for action in arg_state.action._choices_actions: if action.dest in arg_state.action.choices: subparser = arg_state.action.choices[action.dest] parser_help[subparser] = action.help or '' - for name, subparser in arg_state.action.choices.items(): - items.append(CompletionItem(name, [parser_help.get(subparser, '')])) - arg_choices = items - else: - arg_choices = list(arg_state.action.choices) - if not arg_choices: - return Completions() + return [ + CompletionItem(name, display_meta=parser_help.get(subparser, '')) + for name, subparser in arg_state.action.choices.items() + ] - # If these choices are numbers, then sort them now - if all(isinstance(x, numbers.Number) for x in arg_choices): - arg_choices.sort() - choices_sorted = True + # Standard choices + return [ + choice if isinstance(choice, CompletionItem) else CompletionItem(choice) for choice in arg_state.action.choices + ] - # Since choices can be various types, make sure they are all strings - for index, choice in enumerate(arg_choices): - # Prevent converting anything that is already a str (i.e. CompletionItem) - if not isinstance(choice, str): - arg_choices[index] = str(choice) # type: ignore[unreachable] - else: - choices_attr = arg_state.action.get_choices_callable() # type: ignore[attr-defined] - if choices_attr is None: - return Completions() - arg_choices = choices_attr + choices_callable: ChoicesCallable | None = arg_state.action.get_choices_callable() # type: ignore[attr-defined] + return choices_callable - # If we are going to call a completer/choices function, then set up the common arguments - args = [] - kwargs = {} + def _prepare_callable_params( + self, + choices_callable: ChoicesCallable, + arg_state: _ArgumentState, + text: str, + consumed_arg_values: dict[str, list[str]], + cmd_set: CommandSet | None, + ) -> tuple[list[Any], dict[str, Any]]: + """Resolve the instance and arguments required to call a choices/completer function.""" + args: list[Any] = [] + kwargs: dict[str, Any] = {} - # The completer may or may not be defined in the same class as the command. Since completer - # functions are registered with the command argparser before anything is instantiated, we - # need to find an instance at runtime that matches the types during declaration - if isinstance(arg_choices, ChoicesCallable): - self_arg = self._cmd2_app._resolve_func_self(arg_choices.to_call, cmd_set) + # Resolve the 'self' instance for the method + self_arg = self._cmd2_app._resolve_func_self(choices_callable.to_call, cmd_set) + if self_arg is None: + raise CompletionError("Could not find CommandSet instance matching defining type for completer") - if self_arg is None: - # No cases matched, raise an error - raise CompletionError('Could not find CommandSet instance matching defining type for completer') + args.append(self_arg) - args.append(self_arg) + # Check if the function expects 'arg_tokens' + to_call_params = inspect.signature(choices_callable.to_call).parameters + if ARG_TOKENS in to_call_params: + arg_tokens = {**self._parent_tokens, **consumed_arg_values} + arg_tokens.setdefault(arg_state.action.dest, []).append(text) + kwargs[ARG_TOKENS] = arg_tokens - # Check if arg_choices.to_call expects arg_tokens - to_call_params = inspect.signature(arg_choices.to_call).parameters - if ARG_TOKENS in to_call_params: - # Merge self._parent_tokens and consumed_arg_values - arg_tokens = {**self._parent_tokens, **consumed_arg_values} + return args, kwargs - # Include the token being completed - arg_tokens.setdefault(arg_state.action.dest, []).append(text) + def _complete_arg( + self, + text: str, + line: str, + begidx: int, + endidx: int, + arg_state: _ArgumentState, + consumed_arg_values: dict[str, list[str]], + *, + cmd_set: CommandSet | None = None, + ) -> Completions: + """Completion routine for an argparse argument. - # Add the namespace to the keyword arguments for the function we are calling - kwargs[ARG_TOKENS] = arg_tokens + :return: a Completions object + :raises CompletionError: if the completer or choices function this calls raises one + """ + raw_choices = self._get_raw_choices(arg_state) + if not raw_choices: + return Completions() - # Check if the argument uses a specific completion function to provide its choices - if isinstance(arg_choices, ChoicesCallable) and arg_choices.is_completer: + # Check if the argument uses a completer function + if isinstance(raw_choices, ChoicesCallable) and raw_choices.is_completer: + args, kwargs = self._prepare_callable_params(raw_choices, arg_state, text, consumed_arg_values, cmd_set) args.extend([text, line, begidx, endidx]) - completions = arg_choices.completer(*args, **kwargs) # type: ignore[arg-type] + completions = raw_choices.completer(*args, **kwargs) - # Otherwise use basic_complete on the choices + # Otherwise it uses a choices list or choices provider function else: - # Check if the choices come from a function - completion_items: list[str] | list[CompletionItem] = [] - if isinstance(arg_choices, ChoicesCallable): - if not arg_choices.is_completer: - choices_func = arg_choices.choices_provider - if isinstance(choices_func, ChoicesProviderFuncWithTokens): - completion_items = choices_func(*args, **kwargs) - else: # pragma: no cover - # This won't hit because runtime checking doesn't check function argument types and will always - # resolve true above. - completion_items = choices_func(*args) - # else case is already covered above + all_choices: list[CompletionItem] = [] + + if isinstance(raw_choices, ChoicesCallable): + args, kwargs = self._prepare_callable_params(raw_choices, arg_state, text, consumed_arg_values, cmd_set) + choices_func = raw_choices.choices_provider + + if isinstance(choices_func, ChoicesProviderFuncWithTokens): + all_choices = list(choices_func(*args, **kwargs)) + else: + all_choices = list(choices_func(*args)) else: - completion_items = arg_choices + all_choices = raw_choices - # Filter out arguments we already used + # Filter used values and run basic completion used_values = consumed_arg_values.get(arg_state.action.dest, []) - completion_items = [choice for choice in completion_items if choice not in used_values] - - # Do completion on the choices - completions = self._cmd2_app.basic_complete(text, line, begidx, endidx, completion_items) - if choices_sorted: - completions.matches_sorted = choices_sorted + filtered = [choice for choice in all_choices if choice.text not in used_values] + completions = self._cmd2_app.basic_complete(text, line, begidx, endidx, filtered) - self._prepare_formatted_exceptions(arg_state, completions) - return completions + return self._format_completions(arg_state, completions) # The default ArgparseCompleter class for a cmd2 app diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index aa1c48ec4..083db8580 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -49,13 +49,13 @@ parser.add_argument('-o', '--options', choices=my_list) ``choices_provider`` - pass a function that returns choices. This is good in -cases where the choice list is dynamically generated when the user hits tab. +cases where the choices are dynamically generated when the user hits tab. Example:: def my_choices_provider(self): ... - return my_generated_list + return my_choices parser.add_argument("arg", choices_provider=my_choices_provider) @@ -137,10 +137,10 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) The left-most column is the actual value being completed and its header is that value's name. The right column header is defined using the -``descriptive_headers`` parameter of add_argument(), which is a list of header +``table_header`` parameter of add_argument(), which is a list of header names that defaults to ["Description"]. The right column values come from the -``CompletionItem.descriptive_data`` member, which is a list with the same number -of items as columns defined in descriptive_headers. +``row_data`` argument to ``CompletionItem``. It's a ``Sequence`` with the +same number of items as ``table_header``. To use CompletionItems, just return them from your choices_provider or completer functions. They can also be used as argparse choices. When a @@ -151,14 +151,14 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) Example:: - Add an argument and define its descriptive_headers. + Add an argument and define its table_header. parser.add_argument( add_argument( "item_id", type=int, choices_provider=get_items, - descriptive_headers=["Item Name", "Checked Out", "Due Date"], + table_header=["Item Name", "Checked Out", "Due Date"], ) Implement the choices_provider to return CompletionItems. @@ -166,12 +166,12 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) def get_items(self) -> list[CompletionItems]: \"\"\"choices_provider which returns CompletionItems\"\"\" - # CompletionItem's second argument is descriptive_data. - # Its item count should match that of descriptive_headers. + # Populate CompletionItem's table_row argument. + # Its item count should match that of table_header. return [ - CompletionItem(1, ["My item", True, "02/02/2022"]), - CompletionItem(2, ["Another item", False, ""]), - CompletionItem(3, ["Yet another item", False, ""]), + CompletionItem(1, table_row=["My item", True, "02/02/2022"]), + CompletionItem(2, table_row=["Another item", False, ""]), + CompletionItem(3, table_row=["Yet another item", False, ""]), ] This is what the user will see during completion. @@ -182,7 +182,7 @@ def get_items(self) -> list[CompletionItems]: 2 Another item False 3 Yet another item False -``descriptive_headers`` can be strings or ``Rich.table.Columns`` for more +``table_header`` can be strings or ``Rich.table.Columns`` for more control over things like alignment. - If a header is a string, it will render as a left-aligned column with its @@ -194,14 +194,13 @@ def get_items(self) -> list[CompletionItems]: truncated with an ellipsis at the end. You can override this and other settings when you create the ``Column``. -``descriptive_data`` items can include Rich objects, including styled Text and Tables. +``table_row`` items can include Rich objects, including styled Text and Tables. To avoid printing a excessive information to the screen at once when a user presses tab, there is a maximum threshold for the number of CompletionItems -that will be shown. Its value is defined in ``cmd2.Cmd.max_completion_items``. +that will be shown. Its value is defined in ``cmd2.Cmd.max_completion_table_items``. It defaults to 50, but can be changed. If the number of completion suggestions -exceeds this number, they will be displayed in the typical columnized format -and will not include the descriptive_data of the CompletionItems. +exceeds this number, they then a completion table won't be displayed. **Patched argparse functions** @@ -210,12 +209,6 @@ def get_items(self) -> list[CompletionItems]: completion and enables nargs range parsing. See _add_argument_wrapper for more details on these arguments. -``argparse.ArgumentParser._check_value`` - adds support for using -``CompletionItems`` as argparse choices. When evaluating choices, input is -compared to ``CompletionItem.orig_value`` instead of the ``CompletionItem`` -instance. -See _ArgumentParser_check_value for more details. - ``argparse.ArgumentParser._get_nargs_pattern`` - adds support for nargs ranges. See _get_nargs_pattern_wrapper for more details. @@ -234,8 +227,8 @@ def get_items(self) -> list[CompletionItems]: - ``argparse.Action.get_choices_callable()`` - See `action_get_choices_callable` for more details. - ``argparse.Action.set_choices_provider()`` - See `_action_set_choices_provider` for more details. - ``argparse.Action.set_completer()`` - See `_action_set_completer` for more details. -- ``argparse.Action.get_descriptive_headers()`` - See `_action_get_descriptive_headers` for more details. -- ``argparse.Action.set_descriptive_headers()`` - See `_action_set_descriptive_headers` for more details. +- ``argparse.Action.get_table_header()`` - See `_action_get_table_header` for more details. +- ``argparse.Action.set_table_header()`` - See `_action_set_table_header` for more details. - ``argparse.Action.get_nargs_range()`` - See `_action_get_nargs_range` for more details. - ``argparse.Action.set_nargs_range()`` - See `_action_set_nargs_range` for more details. - ``argparse.Action.get_suppress_tab_hint()`` - See `_action_get_suppress_tab_hint` for more details. @@ -295,7 +288,6 @@ def get_items(self) -> list[CompletionItems]: CompleterFunc, CompleterFuncBase, CompleterFuncWithTokens, - CompletionItem, ) from .rich_utils import Cmd2RichArgparseConsole from .styles import Cmd2Style @@ -431,8 +423,8 @@ def choices_provider(self) -> ChoicesProviderFunc: # ChoicesCallable object that specifies the function to be called which provides choices to the argument ATTR_CHOICES_CALLABLE = 'choices_callable' -# Descriptive header that prints when using CompletionItems -ATTR_DESCRIPTIVE_HEADERS = 'descriptive_headers' +# A completion table header +ATTR_TABLE_HEADER = 'table_header' # A tuple specifying nargs as a range (min, max) ATTR_NARGS_RANGE = 'nargs_range' @@ -529,38 +521,38 @@ def _action_set_completer( ############################################################################################################ -# Patch argparse.Action with accessors for descriptive_headers attribute +# Patch argparse.Action with accessors for table_header attribute ############################################################################################################ -def _action_get_descriptive_headers(self: argparse.Action) -> Sequence[str | Column] | None: - """Get the descriptive_headers attribute of an argparse Action. +def _action_get_table_header(self: argparse.Action) -> Sequence[str | Column] | None: + """Get the table_header attribute of an argparse Action. - This function is added by cmd2 as a method called ``get_descriptive_headers()`` to ``argparse.Action`` class. + This function is added by cmd2 as a method called ``get_table_header()`` to ``argparse.Action`` class. - To call: ``action.get_descriptive_headers()`` + To call: ``action.get_table_header()`` :param self: argparse Action being queried - :return: The value of descriptive_headers or None if attribute does not exist + :return: The value of table_header or None if attribute does not exist """ - return cast(Sequence[str | Column] | None, getattr(self, ATTR_DESCRIPTIVE_HEADERS, None)) + return cast(Sequence[str | Column] | None, getattr(self, ATTR_TABLE_HEADER, None)) -setattr(argparse.Action, 'get_descriptive_headers', _action_get_descriptive_headers) +setattr(argparse.Action, 'get_table_header', _action_get_table_header) -def _action_set_descriptive_headers(self: argparse.Action, descriptive_headers: Sequence[str | Column] | None) -> None: - """Set the descriptive_headers attribute of an argparse Action. +def _action_set_table_header(self: argparse.Action, table_header: Sequence[str | Column] | None) -> None: + """Set the table_header attribute of an argparse Action. - This function is added by cmd2 as a method called ``set_descriptive_headers()`` to ``argparse.Action`` class. + This function is added by cmd2 as a method called ``set_table_header()`` to ``argparse.Action`` class. - To call: ``action.set_descriptive_headers(descriptive_headers)`` + To call: ``action.set_table_header(table_header)`` :param self: argparse Action being updated - :param descriptive_headers: value being assigned + :param table_header: value being assigned """ - setattr(self, ATTR_DESCRIPTIVE_HEADERS, descriptive_headers) + setattr(self, ATTR_TABLE_HEADER, table_header) -setattr(argparse.Action, 'set_descriptive_headers', _action_set_descriptive_headers) +setattr(argparse.Action, 'set_table_header', _action_set_table_header) ############################################################################################################ @@ -711,7 +703,7 @@ def _add_argument_wrapper( choices_provider: ChoicesProviderFunc | None = None, completer: CompleterFunc | None = None, suppress_tab_hint: bool = False, - descriptive_headers: Sequence[str | Column] | None = None, + table_header: Sequence[str | Column] | None = None, **kwargs: Any, ) -> argparse.Action: """Wrap ActionsContainer.add_argument() which supports more settings used by cmd2. @@ -731,8 +723,7 @@ def _add_argument_wrapper( current argument's help text as a hint. Set this to True to suppress the hint. If this argument's help text is set to argparse.SUPPRESS, then tab hints will not display regardless of the value passed for suppress_tab_hint. Defaults to False. - :param descriptive_headers: if the provided choices are CompletionItems, then these are the headers - of the descriptive data. Defaults to None. + :param table_header: optional header for when displaying a completion table. Defaults to None. # Args from original function :param kwargs: keyword-arguments recognized by argparse._ActionsContainer.add_argument @@ -823,7 +814,7 @@ def _add_argument_wrapper( new_arg.set_completer(completer) # type: ignore[attr-defined] new_arg.set_suppress_tab_hint(suppress_tab_hint) # type: ignore[attr-defined] - new_arg.set_descriptive_headers(descriptive_headers) # type: ignore[attr-defined] + new_arg.set_table_header(table_header) # type: ignore[attr-defined] for keyword, value in custom_attribs.items(): attr_setter = getattr(new_arg, f'set_{keyword}', None) @@ -930,37 +921,6 @@ def _ArgumentParser_set_ap_completer_type(self: argparse.ArgumentParser, ap_comp setattr(argparse.ArgumentParser, 'set_ap_completer_type', _ArgumentParser_set_ap_completer_type) -############################################################################################################ -# Patch ArgumentParser._check_value to support CompletionItems as choices -############################################################################################################ -def _ArgumentParser_check_value(_self: argparse.ArgumentParser, action: argparse.Action, value: Any) -> None: # noqa: N802 - """Check_value that supports CompletionItems as choices (Custom override of ArgumentParser._check_value). - - When evaluating choices, input is compared to CompletionItem.orig_value instead of the - CompletionItem instance. - - :param self: ArgumentParser instance - :param action: the action being populated - :param value: value from command line already run through conversion function by argparse - """ - # Import gettext like argparse does - from gettext import ( - gettext as _, - ) - - # converted value must be one of the choices (if specified) - if action.choices is not None: - # If any choice is a CompletionItem, then use its orig_value property. - choices = [c.orig_value if isinstance(c, CompletionItem) else c for c in action.choices] - if value not in choices: - args = {'value': value, 'choices': ', '.join(map(repr, choices))} - msg = _('invalid choice: %(value)r (choose from %(choices)s)') - raise ArgumentError(action, msg % args) - - -setattr(argparse.ArgumentParser, '_check_value', _ArgumentParser_check_value) - - ############################################################################################################ # Patch argparse._SubParsersAction to add remove_parser function ############################################################################################################ diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index be5947bed..c799db2f8 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -30,6 +30,7 @@ import argparse import contextlib import copy +import dataclasses import functools import glob import inspect @@ -98,10 +99,12 @@ CommandSet, ) from .completion import ( + Choices, ChoicesProviderFunc, CompleterFunc, CompletionItem, Completions, + Matchable, ) from .constants import ( CLASS_ATTR_DEFAULT_HELP_CATEGORY, @@ -284,10 +287,6 @@ class Cmd: DEFAULT_EDITOR = utils.find_editor() - # Sorting keys for strings - ALPHABETICAL_SORT_KEY = su.norm_fold - NATURAL_SORT_KEY = utils.natural_keys - # List for storing transcript test file names testfiles: ClassVar[list[str]] = [] @@ -429,10 +428,9 @@ def _(event: Any) -> None: # pragma: no cover self.scripts_add_to_history = True # Scripts and pyscripts add commands to history self.timing = False # Prints elapsed time for each command - # The maximum number of CompletionItems to display during completion. If the number of completion - # suggestions exceeds this number, they will be displayed in the typical columnized format and will - # not include the description value of the CompletionItems. - self.max_completion_items: int = 50 + # The maximum number of items to display in a completion table. If the number of completion + # suggestions exceeds this number, then no table will appear. + self.max_completion_table_items: int = 50 # The maximum number of completion results to display in a single column (CompleteStyle.COLUMN). # If the number of results exceeds this, CompleteStyle.MULTI_COLUMN will be used. @@ -648,14 +646,6 @@ def _(event: Any) -> None: # pragma: no cover # Key: Category name | Value: Message to display self.disabled_categories: dict[str, str] = {} - # The default key for sorting string results. Its default value performs a case-insensitive alphabetical sort. - # If natural sorting is preferred, then set this to NATURAL_SORT_KEY. - # cmd2 uses this key for sorting: - # command and category names - # alias, macro, settable, and shortcut names - # completion results when self.matches_sorted is False - self.default_sort_key: Callable[[str], str] = Cmd.ALPHABETICAL_SORT_KEY - # Command parsers for this Cmd instance. self._command_parsers: _CommandParsers = _CommandParsers(self) @@ -1184,9 +1174,10 @@ def remove_settable(self, name: str) -> None: def build_settables(self) -> None: """Create the dictionary of user-settable parameters.""" - def get_allow_style_choices(_cli_self: Cmd) -> list[str]: + def get_allow_style_choices(_cli_self: Cmd) -> Choices: """Complete allow_style values.""" - return [val.name.lower() for val in ru.AllowStyle] + styles = [val.name.lower() for val in ru.AllowStyle] + return Choices.from_strings(styles) def allow_style_type(value: str) -> ru.AllowStyle: """Convert a string value into an ru.AllowStyle.""" @@ -1204,7 +1195,7 @@ def allow_style_type(value: str) -> ru.AllowStyle: 'Allow ANSI text style sequences in output (valid values: ' f'{ru.AllowStyle.ALWAYS}, {ru.AllowStyle.NEVER}, {ru.AllowStyle.TERMINAL})', self, - choices_provider=cast(ChoicesProviderFunc, get_allow_style_choices), + choices_provider=get_allow_style_choices, ) ) @@ -1216,7 +1207,12 @@ def allow_style_type(value: str) -> ru.AllowStyle: self.add_settable(Settable('editor', str, "Program used by 'edit'", self)) self.add_settable(Settable('feedback_to_output', bool, "Include nonessentials in '|' and '>' results", self)) self.add_settable( - Settable('max_completion_items', int, "Maximum number of CompletionItems to display during completion", self) + Settable( + 'max_completion_table_items', + int, + "Maximum number of completion results allowed for a completion table to appear", + self, + ) ) self.add_settable( Settable( @@ -1790,21 +1786,27 @@ def basic_complete( line: str, # noqa: ARG002 begidx: int, # noqa: ARG002 endidx: int, # noqa: ARG002 - match_against: Iterable[str], + match_against: Iterable[Matchable], ) -> Completions: - """Completion function that matches against a list of strings without considering line contents or cursor position. + """Perform completion without considering line contents or cursor position. - The args required by this function are defined in the header of Python's cmd.py. + Strings are matched directly while CompletionItems are matched against their 'text' member. :param text: the string prefix we are attempting to match (all matches must begin with it) :param line: the current input line with leading whitespace removed :param begidx: the beginning index of the prefix text :param endidx: the ending index of the prefix text - :param match_against: the strings being matched against + :param match_against: the items being matched against :return: a Completions object """ - matches = [cur_match for cur_match in match_against if cur_match.startswith(text)] - return Completions(matches) + matches: list[CompletionItem] = [] + + for item in match_against: + candidate = item.text if isinstance(item, CompletionItem) else item + if candidate.startswith(text): + matches.append(item if isinstance(item, CompletionItem) else CompletionItem(item)) + + return Completions(items=matches) def delimiter_complete( self, @@ -1848,18 +1850,13 @@ def delimiter_complete( :return: a Completions object """ basic_completions = self.basic_complete(text, line, begidx, endidx, match_against) - if not basic_completions.matches: + if not basic_completions: return Completions() - completions = Completions() - - # Set this to True for proper quoting of matches with spaces - completions.matches_delimited = True - - # Get the common beginning for the matches - common_prefix = os.path.commonprefix(completions.matches) + match_strings = [item.text for item in basic_completions.items] # Calculate what portion of the match we are completing + common_prefix = os.path.commonprefix(match_strings) prefix_tokens = common_prefix.split(delimiter) display_token_index = len(prefix_tokens) - 1 @@ -1867,26 +1864,31 @@ def delimiter_complete( # This approach can result in duplicates so we will filter those out. unique_results: dict[str, str] = {} - for cur_match in completions.matches: + allow_finalization = True + for cur_match in match_strings: match_tokens = cur_match.split(delimiter) - filtered_match = delimiter.join(match_tokens[: display_token_index + 1]) - display_match = match_tokens[display_token_index] + full_value = delimiter.join(match_tokens[: display_token_index + 1]) + display_val = match_tokens[display_token_index] # If there are more tokens, then we aren't done completing a full item if len(match_tokens) > display_token_index + 1: - filtered_match += delimiter - display_match += delimiter - completions.allow_appended_space = False - completions.allow_closing_quote = False + full_value += delimiter + display_val += delimiter + allow_finalization = False - if filtered_match not in unique_results: - unique_results[filtered_match] = display_match + if full_value not in unique_results: + unique_results[full_value] = display_val - completions.matches = list(unique_results.keys()) - completions.display_matches = list(unique_results.values()) + items = [ + CompletionItem( + value=value, + display=display, + ) + for value, display in unique_results.items() + ] - return completions + return Completions(items, allow_finalization=allow_finalization, is_delimited=True) def flag_based_complete( self, @@ -1894,9 +1896,9 @@ def flag_based_complete( line: str, begidx: int, endidx: int, - flag_dict: dict[str, Iterable[str] | CompleterFunc], + flag_dict: dict[str, Iterable[Matchable] | CompleterFunc], *, - all_else: None | Iterable[str] | CompleterFunc = None, + all_else: None | Iterable[Matchable] | CompleterFunc = None, ) -> Completions: """Completes based on a particular flag preceding the token being completed. @@ -1908,7 +1910,7 @@ def flag_based_complete( `keys` - flags (ex: -c, --create) that result in completion for the next argument in the command line `values` - there are two types of values: - 1. iterable list of strings to match against (dictionaries, lists, etc.) + 1. iterable of Matchables to match against 2. function that performs completion (ex: path_complete) :param all_else: an optional parameter for completing any token that isn't preceded by a flag in flag_dict :return: a Completions object @@ -1942,9 +1944,9 @@ def index_based_complete( line: str, begidx: int, endidx: int, - index_dict: Mapping[int, Iterable[str] | CompleterFunc], + index_dict: Mapping[int, Iterable[Matchable] | CompleterFunc], *, - all_else: Iterable[str] | CompleterFunc | None = None, + all_else: Iterable[Matchable] | CompleterFunc | None = None, ) -> Completions: """Completes based on a fixed position in the input string. @@ -1956,7 +1958,7 @@ def index_based_complete( `keys` - 0-based token indexes into command line that determine which tokens perform tab completion `values` - there are two types of values: - 1. iterable list of strings to match against (dictionaries, lists, etc.) + 1. iterable of Matchables to match against 2. function that performs completion (ex: path_complete) :param all_else: an optional parameter for completing any token that isn't at an index in index_dict :return: a Completions object @@ -1970,8 +1972,7 @@ def index_based_complete( index = len(tokens) - 1 # Check if token is at an index in the dictionary - match_against: Iterable[str] | CompleterFunc | None - match_against = index_dict.get(index, all_else) + match_against: Iterable[Matchable] | CompleterFunc | None = index_dict.get(index, all_else) # Perform completion using a Iterable if isinstance(match_against, Iterable): @@ -1991,7 +1992,7 @@ def _complete_users(text: str, add_trailing_sep_if_dir: bool) -> Completions: :param add_trailing_sep_if_dir: whether a trailing separator should be appended to directory completions :return: a Completions object """ - completions = Completions() + items: list[CompletionItem] = [] # Windows lacks the pwd module so we can't get a list of users. # Instead we will return a result once the user enters text that @@ -2002,7 +2003,7 @@ def _complete_users(text: str, add_trailing_sep_if_dir: bool) -> Completions: user = text if add_trailing_sep_if_dir: user += os.path.sep - completions.matches.append(user) + items.append(CompletionItem(user)) else: import pwd @@ -2015,15 +2016,11 @@ def _complete_users(text: str, add_trailing_sep_if_dir: bool) -> Completions: if cur_user.startswith(text): if add_trailing_sep_if_dir: cur_user += os.path.sep - completions.matches.append(cur_user) + items.append(CompletionItem(cur_user)) - if completions: - # We are returning ~user strings that resolve to directories, - # so don't append a space or quote in the case of a single result. - completions.allow_appended_space = False - completions.allow_closing_quote = False - - return completions + # Since all ~user matches resolve to directories, set allow_finalization to False + # so the user can continue into the subdirectory structure. + return Completions(items=items, allow_finalization=False, is_delimited=True) def path_complete( self, @@ -2093,48 +2090,47 @@ def path_complete( cwd_added = True # Find all matching path completions - completions = Completions() - completions.matches = glob.glob(search_str) + matches = glob.glob(search_str) # Filter out results that don't belong if path_filter is not None: - completions.matches = [c for c in completions.matches if path_filter(c)] - - if completions: - # Set this to True for proper quoting of paths with spaces - completions.matches_delimited = True - - # Don't append a space or closing quote to directory - if len(completions) == 1 and os.path.isdir(completions.matches[0]): - completions.allow_appended_space = False - completions.allow_closing_quote = False - - # Sort the matches before any trailing slashes are added - completions.matches.sort(key=self.default_sort_key) - completions.matches_sorted = True - - # Build display_matches and add a slash to directories - for index, cur_match in enumerate(completions.matches): - # Display only the basename of this path in the completion suggestions - completions.display_matches.append(os.path.basename(cur_match)) - - # Add a separator after directories if the next character isn't already a separator - if os.path.isdir(cur_match) and add_trailing_sep_if_dir: - completions.matches[index] += os.path.sep - completions.display_matches[index] += os.path.sep - - # Remove cwd if it was added to match the text prompt-toolkit expects - if cwd_added: - to_replace = cwd if cwd == os.path.sep else cwd + os.path.sep - completions.matches = [cur_path.replace(to_replace, '', 1) for cur_path in completions.matches] - - # Restore the tilde string if we expanded one to match the text prompt-toolkit expects - if expanded_tilde_path: - completions.matches = [ - cur_path.replace(expanded_tilde_path, orig_tilde_path, 1) for cur_path in completions.matches - ] + matches = [c for c in matches if path_filter(c)] - return completions + if not matches: + return Completions() + + # If we have a single match and it's a directory, then don't append a space or closing quote + allow_finalization = not (len(matches) == 1 and os.path.isdir(matches[0])) + + # Build display_matches and add a slash to directories + display_matches: list[str] = [] + for index, cur_match in enumerate(matches): + # Display only the basename of this path in the completion suggestions + display_matches.append(os.path.basename(cur_match)) + + # Add a separator after directories if the next character isn't already a separator + if os.path.isdir(cur_match) and add_trailing_sep_if_dir: + matches[index] += os.path.sep + display_matches[index] += os.path.sep + + # Remove cwd if it was added to match the text prompt-toolkit expects + if cwd_added: + to_replace = cwd if cwd == os.path.sep else cwd + os.path.sep + matches = [cur_path.replace(to_replace, '', 1) for cur_path in matches] + + # Restore the tilde string if we expanded one to match the text prompt-toolkit expects + if expanded_tilde_path: + matches = [cur_path.replace(expanded_tilde_path, orig_tilde_path, 1) for cur_path in matches] + + items = [ + CompletionItem( + value=match, + display=display, + ) + for match, display in zip(matches, display_matches, strict=True) + ] + + return Completions(items=items, allow_finalization=allow_finalization, is_delimited=True) def shell_cmd_complete( self, text: str, line: str, begidx: int, endidx: int, *, complete_blank: bool = False @@ -2155,8 +2151,8 @@ def shell_cmd_complete( # If there are no path characters in the search text, then do shell command completion in the user's path if not text.startswith('~') and os.path.sep not in text: - exes = utils.get_exes_in_path(text) - return Completions(exes) + items = [CompletionItem(exe) for exe in utils.get_exes_in_path(text)] + return Completions(items=items) # Otherwise look for executables in the given path return self.path_complete( @@ -2390,50 +2386,74 @@ def _perform_completion( # Attempt completion for redirection first, and if that isn't occurring, # call the completer function for the current command completions = self._redirect_complete(text, line, begidx, endidx, completer_func) + if not completions: + return Completions() - if completions: - # Eliminate duplicates - completions.matches = utils.remove_duplicates(completions.matches) - completions.display_matches = utils.remove_duplicates(completions.display_matches) + # Create a mutable list to possibly make changes to items + working_items = list(completions.items) - if not completions.display_matches: - # Since display_matches is empty, set it to matches before we alter them. - # That way the suggestions will reflect how we parsed the token being completed - # and not how prompt-toolkit did. - import copy + # Check if we need to add an opening quote + if not completion_token_quote: + add_quote = False - completions.display_matches = copy.copy(completions.matches) + # Extract the raw text and display strings for quote detection + current_texts = [item.text for item in working_items] - # Check if we need to add an opening quote - if not completion_token_quote: - add_quote = False + # For delimited matches, check for a space in the common text prefix + # as well as in the display segments. + if completions.is_delimited: + common_prefix = os.path.commonprefix(current_texts) + current_displays = [item.display for item in working_items] + + if ' ' in common_prefix or any(' ' in display for display in current_displays): + add_quote = True - # This is the completion text that will appear on the command line. - common_prefix = os.path.commonprefix(completions.matches) + # Otherwise check if any text match has a space. + elif any(' ' in text for text in current_texts): + add_quote = True + + if add_quote: + # Determine best quote (single vs double) based on text content + completion_token_quote = "'" if any('"' in t for t in current_texts) else '"' + working_items = [ + dataclasses.replace( + item, + text=completion_token_quote + item.text, + ) + for item in working_items + ] - if completions.matches_delimited: - # For delimited matches, we check for a space in what appears before the display - # matches (common_prefix) as well as in the display matches themselves. - if ' ' in common_prefix or any(' ' in match for match in completions.display_matches): - add_quote = True + # Check if we need to remove text from the beginning of completions + elif text_to_remove: + working_items = [ + dataclasses.replace( + item, + text=item.text.replace(text_to_remove, '', 1), + ) + for item in working_items + ] - # If there is a completion and any match has a space, then add an opening quote - elif any(' ' in match for match in completions.matches): - add_quote = True + # Handle closing quote and trailing space + if len(working_items) == 1 and completions.allow_finalization: + final_item = working_items[0] + new_text = final_item.text - if add_quote: - # Figure out what kind of quote to add and save it as the unclosed_quote - completion_token_quote = "'" if any('"' in match for match in completions.matches) else '"' + # Append the matching quote if we started one + if completion_token_quote: + new_text += completion_token_quote - completions.matches = [completion_token_quote + match for match in completions.matches] + # Append a space if the cursor is at the end of the line + if endidx == len(line): + new_text += ' ' - # Check if we need to remove text from the beginning of completions - elif text_to_remove: - completions.matches = [match.replace(text_to_remove, '', 1) for match in completions.matches] + # Update the item if the text changed + if new_text != final_item.text: + working_items = [dataclasses.replace(final_item, text=new_text)] - # If we have one result, then add a closing quote if needed and allowed - if len(completions) == 1 and completions.allow_closing_quote and completion_token_quote: - completions.matches[0] += completion_token_quote + # Convert back to tuple for comparison with the original frozen tuple + final_items_tuple = tuple(working_items) + if final_items_tuple != completions.items: + completions = dataclasses.replace(completions, items=final_items_tuple) return completions @@ -2445,14 +2465,14 @@ def complete( endidx: int, custom_settings: utils.CustomCompletionSettings | None = None, ) -> Completions: - """Handle completion for an input line and return a validated Completions object. + """Handle completion for an input line. :param text: the current word that user is typing :param line: current input line :param begidx: beginning index of text :param endidx: ending index of text :param custom_settings: used when not completing the main command line - :return: a validated Completions object + :return: a Completions object :raises CompletionError: if a completion-related exception occurs :raises Exception: for any unhandled underlying processing errors """ @@ -2497,7 +2517,7 @@ def complete( 'command', metavar="COMMAND", help="command, alias, or macro name", - choices=self._get_commands_aliases_and_macros_for_completion(), + choices=self._get_commands_aliases_and_macros_choices(), ) custom_settings = utils.CustomCompletionSettings(parser) @@ -2505,18 +2525,15 @@ def complete( # Check if we need to restore a shortcut in the completions # so it doesn't get erased from the command line - if shortcut_to_restore: - completions.matches = [shortcut_to_restore + match for match in completions.matches] - - # If we have one result and we are at the end of the line, then add a space if allowed - if len(completions) == 1 and endidx == len(line) and completions.allow_appended_space: - completions.matches[0] += ' ' - - # Sort matches if they haven't already been sorted - if not completions.matches_sorted: - completions.matches.sort(key=self.default_sort_key) - completions.display_matches.sort(key=self.default_sort_key) - completions.matches_sorted = True + if completions and shortcut_to_restore: + new_items = [ + dataclasses.replace( + item, + text=shortcut_to_restore + item.text, + ) + for item in completions.items + ] + completions = dataclasses.replace(completions, items=new_items) # Swap between COLUMN and MULTI_COLUMN style based on the number of matches if not using READLINE_LIKE if len(completions) > self.max_column_completion_results: @@ -2524,8 +2541,6 @@ def complete( else: self.session.complete_style = CompleteStyle.COLUMN - # Run validation before returning - completions.validate() return completions def in_script(self) -> bool: @@ -2561,59 +2576,57 @@ def get_visible_commands(self) -> list[str]: if command not in self.hidden_commands and command not in self.disabled_commands ] - def _get_alias_completion_items(self) -> list[CompletionItem]: - """Return list of alias names and values as CompletionItems.""" - results: list[CompletionItem] = [] + def _get_alias_choices(self) -> Choices: + """Return list of alias names and values as Choices.""" + items: list[CompletionItem] = [] for name, value in self.aliases.items(): - descriptive_data = [value] - results.append(CompletionItem(name, descriptive_data)) + items.append(CompletionItem(name, display_meta=value, table_row=[value])) - return results + return Choices(items=items) - def _get_macro_completion_items(self) -> list[CompletionItem]: - """Return list of macro names and values as CompletionItems.""" - results: list[CompletionItem] = [] + def _get_macro_choices(self) -> Choices: + """Return list of macro names and values as Choices.""" + items: list[CompletionItem] = [] for name, macro in self.macros.items(): - descriptive_data = [macro.value] - results.append(CompletionItem(name, descriptive_data)) + items.append(CompletionItem(name, display_meta=macro.value, table_row=[macro.value])) - return results + return Choices(items=items) - def _get_settable_completion_items(self) -> list[CompletionItem]: - """Return list of Settable names, values, and descriptions as CompletionItems.""" - results: list[CompletionItem] = [] + def _get_settable_choices(self) -> Choices: + """Return list of Settable names, values, and descriptions as Choices.""" + items: list[CompletionItem] = [] for name, settable in self.settables.items(): - descriptive_data = [ + table_row = [ str(settable.value), settable.description, ] - results.append(CompletionItem(name, descriptive_data)) + items.append(CompletionItem(name, display_meta=str(settable.value), table_row=table_row)) - return results + return Choices(items=items) - def _get_commands_aliases_and_macros_for_completion(self) -> list[CompletionItem]: - """Return a list of visible commands, aliases, and macros for completion.""" - results: list[CompletionItem] = [] + def _get_commands_aliases_and_macros_choices(self) -> Choices: + """Return a list of visible commands, aliases, and macros as Choices.""" + items: list[CompletionItem] = [] # Add commands for command in self.get_visible_commands(): # Get the command method func = getattr(self, constants.COMMAND_FUNC_PREFIX + command) description = strip_doc_annotations(func.__doc__).splitlines()[0] if func.__doc__ else '' - results.append(CompletionItem(command, [description])) + items.append(CompletionItem(command, display_meta=description)) # Add aliases for name, value in self.aliases.items(): - results.append(CompletionItem(name, [f"Alias for: {value}"])) + items.append(CompletionItem(name, display_meta=f"Alias for: {value}")) # Add macros for name, macro in self.macros.items(): - results.append(CompletionItem(name, [f"Macro: {macro.value}"])) + items.append(CompletionItem(name, display_meta=f"Macro: {macro.value}")) - return results + return Choices(items=items) def get_help_topics(self) -> list[str]: """Return a list of help topics.""" @@ -3540,7 +3553,7 @@ def _build_alias_create_parser(cls) -> Cmd2ArgumentParser: alias_create_parser.add_argument( 'command', help='command, alias, or macro to run', - choices_provider=cls._get_commands_aliases_and_macros_for_completion, + choices_provider=cls._get_commands_aliases_and_macros_choices, ) alias_create_parser.add_argument( 'command_args', @@ -3598,8 +3611,8 @@ def _build_alias_delete_parser(cls) -> Cmd2ArgumentParser: 'names', nargs=argparse.ZERO_OR_MORE, help='alias(es) to delete', - choices_provider=cls._get_alias_completion_items, - descriptive_headers=["Value"], + choices_provider=cls._get_alias_choices, + table_header=["Value"], ) return alias_delete_parser @@ -3640,8 +3653,8 @@ def _build_alias_list_parser(cls) -> Cmd2ArgumentParser: 'names', nargs=argparse.ZERO_OR_MORE, help='alias(es) to list', - choices_provider=cls._get_alias_completion_items, - descriptive_headers=["Value"], + choices_provider=cls._get_alias_choices, + table_header=["Value"], ) return alias_list_parser @@ -3654,7 +3667,14 @@ def _alias_list(self, args: argparse.Namespace) -> None: tokens_to_quote = constants.REDIRECTION_TOKENS tokens_to_quote.extend(self.statement_parser.terminators) - to_list = utils.remove_duplicates(args.names) if args.names else sorted(self.aliases, key=self.default_sort_key) + to_list = ( + utils.remove_duplicates(args.names) + if args.names + else sorted( + self.aliases, + key=utils.DEFAULT_STR_SORT_KEY, + ) + ) not_found: list[str] = [] for name in to_list: @@ -3693,8 +3713,6 @@ def macro_arg_complete( Its default behavior is to call path_complete, but you can override this as needed. - The args required by this function are defined in the header of Python's cmd.py. - :param text: the string prefix we are attempting to match (all matches must begin with it) :param line: the current input line with leading whitespace removed :param begidx: the beginning index of the prefix text @@ -3783,7 +3801,7 @@ def _build_macro_create_parser(cls) -> Cmd2ArgumentParser: macro_create_parser.add_argument( 'command', help='command, alias, or macro to run', - choices_provider=cls._get_commands_aliases_and_macros_for_completion, + choices_provider=cls._get_commands_aliases_and_macros_choices, ) macro_create_parser.add_argument( 'command_args', @@ -3884,8 +3902,8 @@ def _build_macro_delete_parser(cls) -> Cmd2ArgumentParser: 'names', nargs=argparse.ZERO_OR_MORE, help='macro(s) to delete', - choices_provider=cls._get_macro_completion_items, - descriptive_headers=["Value"], + choices_provider=cls._get_macro_choices, + table_header=["Value"], ) return macro_delete_parser @@ -3926,8 +3944,8 @@ def _build_macro_list_parser(cls) -> Cmd2ArgumentParser: 'names', nargs=argparse.ZERO_OR_MORE, help='macro(s) to list', - choices_provider=cls._get_macro_completion_items, - descriptive_headers=["Value"], + choices_provider=cls._get_macro_choices, + table_header=["Value"], ) return macro_list_parser @@ -3940,7 +3958,14 @@ def _macro_list(self, args: argparse.Namespace) -> None: tokens_to_quote = constants.REDIRECTION_TOKENS tokens_to_quote.extend(self.statement_parser.terminators) - to_list = utils.remove_duplicates(args.names) if args.names else sorted(self.macros, key=self.default_sort_key) + to_list = ( + utils.remove_duplicates(args.names) + if args.names + else sorted( + self.macros, + key=utils.DEFAULT_STR_SORT_KEY, + ) + ) not_found: list[str] = [] for name in to_list: @@ -3998,10 +4023,10 @@ def _build_command_info(self) -> tuple[dict[str, list[str]], list[str], list[str - list of help topic names that are not also commands """ # Get a sorted list of help topics - help_topics = sorted(self.get_help_topics(), key=self.default_sort_key) + help_topics = sorted(self.get_help_topics(), key=utils.DEFAULT_STR_SORT_KEY) # Get a sorted list of visible command names - visible_commands = sorted(self.get_visible_commands(), key=self.default_sort_key) + visible_commands = sorted(self.get_visible_commands(), key=utils.DEFAULT_STR_SORT_KEY) cmds_doc: list[str] = [] cmds_undoc: list[str] = [] cmds_cats: dict[str, list[str]] = {} @@ -4066,7 +4091,7 @@ def do_help(self, args: argparse.Namespace) -> None: self.poutput() # Print any categories first and then the remaining documented commands. - sorted_categories = sorted(cmds_cats.keys(), key=self.default_sort_key) + sorted_categories = sorted(cmds_cats.keys(), key=utils.DEFAULT_STR_SORT_KEY) all_cmds = {category: cmds_cats[category] for category in sorted_categories} if all_cmds: all_cmds[self.default_category] = cmds_doc @@ -4283,7 +4308,7 @@ def _build_shortcuts_parser() -> Cmd2ArgumentParser: def do_shortcuts(self, _: argparse.Namespace) -> None: """List available shortcuts.""" # Sort the shortcut tuples by name - sorted_shortcuts = sorted(self.statement_parser.shortcuts, key=lambda x: self.default_sort_key(x[0])) + sorted_shortcuts = sorted(self.statement_parser.shortcuts, key=lambda x: utils.DEFAULT_STR_SORT_KEY(x[0])) result = "\n".join(f'{sc[0]}: {sc[1]}' for sc in sorted_shortcuts) self.poutput(f"Shortcuts for other commands:\n{result}") self.last_result = True @@ -4388,8 +4413,8 @@ def _build_base_set_parser(cls) -> Cmd2ArgumentParser: 'param', nargs=argparse.OPTIONAL, help='parameter to set or view', - choices_provider=cls._get_settable_completion_items, - descriptive_headers=["Value", "Description"], + choices_provider=cls._get_settable_choices, + table_header=["Value", "Description"], ) return base_set_parser @@ -4487,7 +4512,7 @@ def do_set(self, args: argparse.Namespace) -> None: # Build the table and populate self.last_result self.last_result = {} # dict[settable_name, settable_value] - for param in sorted(to_show, key=self.default_sort_key): + for param in sorted(to_show, key=utils.DEFAULT_STR_SORT_KEY): settable = self.settables[param] settable_table.add_row( param, diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py index 963df24d7..e7c7672a5 100644 --- a/cmd2/command_definition.py +++ b/cmd2/command_definition.py @@ -3,6 +3,7 @@ from collections.abc import Callable, Mapping from typing import ( TYPE_CHECKING, + TypeAlias, TypeVar, ) @@ -13,16 +14,14 @@ from .exceptions import ( CommandSetRegistrationError, ) -from .utils import ( - Settable, -) +from .utils import Settable if TYPE_CHECKING: # pragma: no cover import cmd2 #: Callable signature for a basic command function #: Further refinements are needed to define the input parameters -CommandFunc = Callable[..., bool | None] +CommandFunc: TypeAlias = Callable[..., bool | None] CommandSetType = TypeVar('CommandSetType', bound=type['CommandSet']) diff --git a/cmd2/completion.py b/cmd2/completion.py index b687eabfc..069eb72fd 100644 --- a/cmd2/completion.py +++ b/cmd2/completion.py @@ -1,148 +1,232 @@ """Provides classes and functions related to completion.""" +import re import sys from collections.abc import ( + Iterator, Sequence, ) +from dataclasses import ( + dataclass, + field, +) from typing import ( Any, Protocol, + TypeAlias, + cast, + overload, runtime_checkable, ) -from rich.protocol import is_renderable - if sys.version_info >= (3, 11): from typing import Self else: from typing_extensions import Self +from rich.protocol import is_renderable -from dataclasses import ( - dataclass, - field, +from . import rich_utils as ru +from . import utils + +# Regular expression to identify strings which we should sort numerically +NUMERIC_RE = re.compile( + r""" + ^ # Start of string + [-+]? # Optional sign + (?: # Start of non-capturing group + \d+\.?\d* # Matches 123 or 123. or 123.45 + | # OR + \.\d+ # Matches .45 + ) # End of group + $ # End of string +""", + re.VERBOSE, ) -from . import rich_utils as ru +@dataclass(frozen=True, slots=True, kw_only=True) +class CompletionItem: + """A single completion result.""" -@dataclass(slots=True) -class Completions: - """The result and display configuration for a completion operation. + # The underlying object this completion represents (e.g., a Path, Enum, or int). + # This is used to support argparse choices validation. + value: Any = field(kw_only=False) - Note: Validation of data integrity is performed by Cmd.complete() before returning. - """ + # The actual string that will be inserted into the command line. + # If not provided, it defaults to str(value). + text: str = "" - # The list of completions - matches: list[str] = field(default_factory=list) + # Optional string for displaying the completion differently in the completion menu. + display: str = "" - # Optional strings for displaying the matches differently in the completion menu. - # If populated, it must be the same length as matches. - display_matches: list[str] = field(default_factory=list) + # Optional meta information about completion which displays in the completion menu. + display_meta: str = "" - # Optional meta information about each match which displays in the completion menu. - # If populated, it must be the same length as matches. - display_meta: list[str] = field(default_factory=list) + # Optional row data for completion tables. Length must match the associated argparse + # argument's table_header. This is stored internally as a tuple. + table_row: Sequence[Any] = field(default_factory=tuple) - # If True and a single match is returned to complete(), then a space will be appended - # if the match appears at the end of the line - allow_appended_space: bool = True + def __post_init__(self) -> None: + """Finalize the object after initialization.""" + # Derive text from value if it wasn't explicitly provided + if not self.text: + object.__setattr__(self, "text", str(self.value)) - # If True and a single match is returned to complete(), then a closing quote - # will be added if there is an unmatched opening quote - allow_closing_quote: bool = True + # Ensure display is never blank. + if not self.display: + object.__setattr__(self, "display", self.text) - # An optional hint which prints above completion suggestions - completion_hint: str = "" + # Make sure all table row objects are renderable by a Rich table. + renderable_data = [obj if is_renderable(obj) else str(obj) for obj in self.table_row] + + # Convert strings containing ANSI style sequences to Rich Text objects for correct display width. + object.__setattr__( + self, + 'table_row', + ru.prepare_objects_for_rendering(*renderable_data), + ) + + def __str__(self) -> str: + """Return the completion text.""" + return self.text + + def __eq__(self, other: object) -> bool: + """Compare this CompletionItem for equality. + + Identity is determined by value, text, display, and display_meta. + table_row is excluded from equality checks to ensure that items + with the same functional value are treated as duplicates. + + Also supports comparison against non-CompletionItems to facilitate argparse + choices validation. + """ + if isinstance(other, CompletionItem): + return ( + self.value == other.value + and self.text == other.text + and self.display == other.display + and self.display_meta == other.display_meta + ) + + # This supports argparse validation when a CompletionItem is used as a choice + return bool(self.value == other) + + def __hash__(self) -> int: + """Return a hash of the item's identity fields.""" + return hash((self.value, self.text, self.display, self.display_meta)) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class CompletionResultsBase: + """Base class for results containing a collection of CompletionItems.""" + + # The collection of CompletionItems. This is stored internally as a tuple. + items: Sequence[CompletionItem] = field(default_factory=tuple, kw_only=False) - # Normally cmd2 uses prompt-toolkit's formatter to columnize the list of completion suggestions. - # If a custom format is preferred, write the formatted completions to this string. cmd2 will - # then print it instead of the prompt-toolkit format. ANSI style sequences and newlines are supported - # when using this value. Even when using formatted_completions, the full matches must still be returned - # from your completer function. ArgparseCompleter writes its completion tables to this string. - formatted_completions: str = "" + # If True, indicates the items are already provided in the desired display order. + # If False, items will be sorted by their display value during initialization. + is_sorted: bool = False - # Used by functions like path_complete() and delimiter_complete() to properly - # quote matches that are completed in a delimited fashion - matches_delimited: bool = False + def __post_init__(self) -> None: + """Finalize the object after initialization.""" + unique_items = utils.remove_duplicates(self.items) + if not self.is_sorted: + if all_display_numeric(unique_items): + # Sort numerically + unique_items.sort(key=lambda item: float(item.display)) + else: + # Standard string sort + unique_items.sort(key=lambda item: utils.DEFAULT_STR_SORT_KEY(item.display)) + + object.__setattr__(self, "is_sorted", True) + + object.__setattr__(self, "items", tuple(unique_items)) + + @classmethod + def from_strings(cls, strings: Sequence[str], *, is_sorted: bool = False) -> Self: + """Create a completion results instance from a sequence of strings. + + :param strings: the raw strings to be converted into CompletionItems. + :param is_sorted: whether the strings are already in the desired order. + """ + items = [CompletionItem(s) for s in strings] + return cls(items=items, is_sorted=is_sorted) - # Set to True before returning matches to complete() in cases where matches have already been sorted. - # If False, then complete() will sort the matches using self.default_sort_key before they are displayed. - # This does not affect formatted_completions. - matches_sorted: bool = False + # --- Sequence Protocol Functions --- def __bool__(self) -> bool: - """Return True if there are matches, False otherwise.""" - return bool(self.matches) + """Return True if there are items, False otherwise.""" + return bool(self.items) def __len__(self) -> int: - """Return the number of matches.""" - return len(self.matches) + """Return the number of items.""" + return len(self.items) - def validate(self) -> None: - """Validate data integrity. + def __contains__(self, item: object) -> bool: + """Return True if the item is present in the collection.""" + return item in self.items - :raises ValueError: if there is an issue with the data. - """ - num_matches = len(self.matches) + def __iter__(self) -> Iterator[CompletionItem]: + """Allow the collection to be used in loops or comprehensions.""" + return iter(self.items) - # Check display_matches - if self.display_matches and len(self.display_matches) != num_matches: - raise ValueError( - f"Mismatched display_matches: expected {num_matches} items " - f"(to match 'matches'), but got {len(self.display_matches)}." - ) + def __reversed__(self) -> Iterator[CompletionItem]: + """Allow the collection to be iterated in reverse order using reversed().""" + return reversed(self.items) - # Check display_meta - if self.display_meta and len(self.display_meta) != num_matches: - raise ValueError( - f"Mismatched display_meta: expected {num_matches} items " - f"(to match 'matches'), but got {len(self.display_meta)}." - ) + @overload + def __getitem__(self, index: int) -> CompletionItem: ... + @overload + def __getitem__(self, index: slice) -> tuple[CompletionItem, ...]: ... -class CompletionItem(str): # noqa: SLOT000 - """Completion item with descriptive text attached. + def __getitem__(self, index: int | slice) -> CompletionItem | tuple[CompletionItem, ...]: + """Retrieve an item by its integer index or a range of items using a slice.""" + items_tuple = cast(tuple[CompletionItem, ...], self.items) + return items_tuple[index] - See header of this file for more information - """ - def __new__(cls, value: object, *_args: Any, **_kwargs: Any) -> Self: - """Responsible for creating and returning a new instance, called before __init__ when an object is instantiated.""" - return super().__new__(cls, value) +@dataclass(frozen=True, slots=True, kw_only=True) +class Choices(CompletionResultsBase): + """A collection of potential values available for completion, typically provided by a choice provider.""" - def __init__(self, value: object, descriptive_data: Sequence[Any], *args: Any) -> None: - """CompletionItem Initializer. - :param value: the value being completed - :param descriptive_data: a list of descriptive data to display in the columns that follow - the completion value. The number of items in this list must equal - the number of descriptive headers defined for the argument. - :param args: args for str __init__ - """ - super().__init__(*args) +@dataclass(frozen=True, slots=True, kw_only=True) +class Completions(CompletionResultsBase): + """The results of a completion operation.""" + + # An optional hint which prints above completion suggestions + completion_hint: str = "" - # Make sure all objects are renderable by a Rich table. - renderable_data = [obj if is_renderable(obj) else str(obj) for obj in descriptive_data] + # An optional table string populated by the argparse completer + completion_table: str = "" - # Convert strings containing ANSI style sequences to Rich Text objects for correct display width. - self.descriptive_data = ru.prepare_objects_for_rendering(*renderable_data) + # If True, the completion engine is allowed to finalize a completion + # when a single match is found by appending a trailing space and + # closing any open quotation marks. + # + # Set this to False for intermediate or hierarchical matches (such as + # directories) where the user needs to continue typing the next segment. + # This flag is ignored if there are multiple matches. + allow_finalization: bool = True - # Save the original value to support CompletionItems as argparse choices. - # cmd2 has patched argparse so input is compared to this value instead of the CompletionItem instance. - self._orig_value = value + # If True, indicates that matches represent portions of a hierarchical + # string (e.g., paths or "a::b::c"). This signals the shell to use + # specialized quoting logic. + is_delimited: bool = False - @property - def orig_value(self) -> Any: - """Read-only property for _orig_value.""" - return self._orig_value + +def all_display_numeric(items: Sequence[CompletionItem]) -> bool: + """Return True if items is non-empty and every item.display is a numeric string.""" + return bool(items) and all(NUMERIC_RE.match(item.display) for item in items) @runtime_checkable class ChoicesProviderFuncBase(Protocol): """Function that returns a list of choices in support of completion.""" - def __call__(self) -> list[str]: # pragma: no cover + def __call__(self) -> Choices: # pragma: no cover """Enable instances to be called like functions.""" @@ -150,11 +234,11 @@ def __call__(self) -> list[str]: # pragma: no cover class ChoicesProviderFuncWithTokens(Protocol): """Function that returns a list of choices in support of completion and accepts a dictionary of prior arguments.""" - def __call__(self, *, arg_tokens: dict[str, list[str]] = {}) -> list[str]: # pragma: no cover # noqa: B006 + def __call__(self, *, arg_tokens: dict[str, list[str]] = {}) -> Choices: # pragma: no cover # noqa: B006 """Enable instances to be called like functions.""" -ChoicesProviderFunc = ChoicesProviderFuncBase | ChoicesProviderFuncWithTokens +ChoicesProviderFunc: TypeAlias = ChoicesProviderFuncBase | ChoicesProviderFuncWithTokens @runtime_checkable @@ -187,4 +271,9 @@ def __call__( """Enable instances to be called like functions.""" -CompleterFunc = CompleterFuncBase | CompleterFuncWithTokens +CompleterFunc: TypeAlias = CompleterFuncBase | CompleterFuncWithTokens + +# Represents a type that can be matched against when completing. +# Strings are matched directly while CompletionItems are matched +# against their 'text' member. +Matchable: TypeAlias = str | CompletionItem diff --git a/cmd2/decorators.py b/cmd2/decorators.py index de4bc2e50..1dfa4d841 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -5,6 +5,7 @@ from typing import ( TYPE_CHECKING, Any, + TypeAlias, TypeVar, Union, ) @@ -63,8 +64,7 @@ def cat_decorator(func: CommandFunc) -> CommandFunc: CommandParent = TypeVar('CommandParent', bound=Union['cmd2.Cmd', CommandSet]) CommandParentType = TypeVar('CommandParentType', bound=type['cmd2.Cmd'] | type[CommandSet]) - -RawCommandFuncOptionalBoolReturn = Callable[[CommandParent, Statement | str], bool | None] +RawCommandFuncOptionalBoolReturn: TypeAlias = Callable[[CommandParent, Statement | str], bool | None] ########################## @@ -113,16 +113,16 @@ def _arg_swap(args: Sequence[Any], search_arg: Any, *replace_arg: Any) -> list[A #: Function signature for a command function that accepts a pre-processed argument list from user input #: and optionally returns a boolean -ArgListCommandFuncOptionalBoolReturn = Callable[[CommandParent, list[str]], bool | None] +ArgListCommandFuncOptionalBoolReturn: TypeAlias = Callable[[CommandParent, list[str]], bool | None] #: Function signature for a command function that accepts a pre-processed argument list from user input #: and returns a boolean -ArgListCommandFuncBoolReturn = Callable[[CommandParent, list[str]], bool] +ArgListCommandFuncBoolReturn: TypeAlias = Callable[[CommandParent, list[str]], bool] #: Function signature for a command function that accepts a pre-processed argument list from user input #: and returns Nothing -ArgListCommandFuncNoneReturn = Callable[[CommandParent, list[str]], None] +ArgListCommandFuncNoneReturn: TypeAlias = Callable[[CommandParent, list[str]], None] #: Aggregate of all accepted function signatures for command functions that accept a pre-processed argument list -ArgListCommandFunc = ( +ArgListCommandFunc: TypeAlias = ( ArgListCommandFuncOptionalBoolReturn[CommandParent] | ArgListCommandFuncBoolReturn[CommandParent] | ArgListCommandFuncNoneReturn[CommandParent] @@ -193,21 +193,23 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: #: Function signatures for command functions that use an argparse.ArgumentParser to process user input #: and optionally return a boolean -ArgparseCommandFuncOptionalBoolReturn = Callable[[CommandParent, argparse.Namespace], bool | None] -ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn = Callable[[CommandParent, argparse.Namespace, list[str]], bool | None] +ArgparseCommandFuncOptionalBoolReturn: TypeAlias = Callable[[CommandParent, argparse.Namespace], bool | None] +ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn: TypeAlias = Callable[ + [CommandParent, argparse.Namespace, list[str]], bool | None +] #: Function signatures for command functions that use an argparse.ArgumentParser to process user input #: and return a boolean -ArgparseCommandFuncBoolReturn = Callable[[CommandParent, argparse.Namespace], bool] -ArgparseCommandFuncWithUnknownArgsBoolReturn = Callable[[CommandParent, argparse.Namespace, list[str]], bool] +ArgparseCommandFuncBoolReturn: TypeAlias = Callable[[CommandParent, argparse.Namespace], bool] +ArgparseCommandFuncWithUnknownArgsBoolReturn: TypeAlias = Callable[[CommandParent, argparse.Namespace, list[str]], bool] #: Function signatures for command functions that use an argparse.ArgumentParser to process user input #: and return nothing -ArgparseCommandFuncNoneReturn = Callable[[CommandParent, argparse.Namespace], None] -ArgparseCommandFuncWithUnknownArgsNoneReturn = Callable[[CommandParent, argparse.Namespace, list[str]], None] +ArgparseCommandFuncNoneReturn: TypeAlias = Callable[[CommandParent, argparse.Namespace], None] +ArgparseCommandFuncWithUnknownArgsNoneReturn: TypeAlias = Callable[[CommandParent, argparse.Namespace, list[str]], None] #: Aggregate of all accepted function signatures for an argparse command function -ArgparseCommandFunc = ( +ArgparseCommandFunc: TypeAlias = ( ArgparseCommandFuncOptionalBoolReturn[CommandParent] | ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn[CommandParent] | ArgparseCommandFuncBoolReturn[CommandParent] diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index 07f11f7a3..5cda30cb6 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -7,9 +7,7 @@ Any, ) -from prompt_toolkit import ( - print_formatted_text, -) +from prompt_toolkit import print_formatted_text from prompt_toolkit.completion import ( Completer, Completion, @@ -25,7 +23,6 @@ utils, ) from . import rich_utils as ru -from .completion import CompletionItem from .exceptions import CompletionError from .styles import Cmd2Style @@ -84,37 +81,27 @@ def get_completions(self, document: Document, _complete_event: object) -> Iterab print_formatted_text(ANSI(formatted_exception)) return - # Print formatted completions if present - if completions.formatted_completions: - print_formatted_text(ANSI("\n" + completions.formatted_completions)) + # Print completion table if present + if completions.completion_table: + print_formatted_text(ANSI("\n" + completions.completion_table)) # Print hint if present and settings say we should - if completions.completion_hint and (self.cmd_app.always_show_hint or not completions.matches): + if completions.completion_hint and (self.cmd_app.always_show_hint or not completions): print_formatted_text(ANSI(completions.completion_hint)) - if not completions.matches: + if not completions: return - # Now we iterate over completions.matches and completions.display_matches. - # cmd2 separates completion matches (what is inserted) from display matches (what is shown). - # prompt_toolkit Completion object takes 'text' (what is inserted) and 'display' (what is shown). - - # Check if we have display matches - use_display_matches = bool(completions.display_matches) - - for i, match in enumerate(completions.matches): - display = completions.display_matches[i] if use_display_matches else match - display_meta: str | ANSI | None = None - if isinstance(match, CompletionItem) and match.descriptive_data: - if isinstance(match.descriptive_data[0], str): - display_meta = match.descriptive_data[0] - elif isinstance(match.descriptive_data[0], Text): - # Convert rich renderable to prompt-toolkit formatted text - display_meta = ANSI(ru.rich_text_to_string(match.descriptive_data[0])) - + # Return the completions + for item in completions.items: # Set offset to the start of the current word to overwrite it with the completion start_position = -len(text) - yield Completion(match, start_position=start_position, display=display, display_meta=display_meta) + yield Completion( + item.text, + start_position=start_position, + display=item.display, + display_meta=item.display_meta, + ) class Cmd2History(History): diff --git a/cmd2/utils.py b/cmd2/utils.py index e35b0756d..4fae8c5d6 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -28,13 +28,14 @@ from . import constants from . import string_utils as su -from .argparse_custom import ( +from .completion import ( + Choices, ChoicesProviderFunc, CompleterFunc, ) if TYPE_CHECKING: # pragma: no cover - import cmd2 # noqa: F401 + from .decorators import CommandParent PopenTextIO = subprocess.Popen[str] else: @@ -115,12 +116,12 @@ def __init__( """ if val_type is bool: - def get_bool_choices(_: str) -> list[str]: - """Complete lowercase boolean values.""" - return ['true', 'false'] + def get_bool_choices(_cmd2_self: "CommandParent") -> Choices: + """Tab complete lowercase boolean values.""" + return Choices.from_strings(['true', 'false']) val_type = to_bool - choices_provider = cast(ChoicesProviderFunc, get_bool_choices) + choices_provider = get_bool_choices self.name = name self.val_type = val_type @@ -185,18 +186,17 @@ def is_text_file(file_path: str) -> bool: return valid_text_file -def remove_duplicates(list_to_prune: list[_T]) -> list[_T]: - """Remove duplicates from a list while preserving order of the items. +def remove_duplicates(items: Iterable[_T]) -> list[_T]: + """Remove duplicates from an iterable while preserving order of the items. - :param list_to_prune: the list being pruned of duplicates - :return: The pruned list + :param items: the items being pruned of duplicates + :return: a list containing only the unique items, in order """ - temp_dict = dict.fromkeys(list_to_prune) - return list(temp_dict.keys()) + return list(dict.fromkeys(items)) -def alphabetical_sort(list_to_sort: Iterable[str]) -> list[str]: - """Sorts a list of strings alphabetically. +def alphabetical_sort(items: Iterable[str]) -> list[str]: + """Sorts an iterable of strings alphabetically. For example: ['a1', 'A11', 'A2', 'a22', 'a3'] @@ -204,10 +204,10 @@ def alphabetical_sort(list_to_sort: Iterable[str]) -> list[str]: my_list.sort(key=norm_fold) - :param list_to_sort: the list being sorted - :return: the sorted list + :param items: the strings to sort + :return: a sorted list """ - return sorted(list_to_sort, key=su.norm_fold) + return sorted(items, key=su.norm_fold) def try_int_or_force_to_lower_case(input_str: str) -> int | str: @@ -844,3 +844,18 @@ def get_types(func_or_method: Callable[..., Any]) -> tuple[dict[str, Any], Any]: if inspect.ismethod(func_or_method): type_hints.pop('self', None) # Pop off `self` hint for methods return type_hints, ret_ann + + +# Sorting keys for strings +ALPHABETICAL_SORT_KEY = su.norm_fold +NATURAL_SORT_KEY = natural_keys + +# Application-wide sort key for strings +# Set it using cmd2.set_default_str_sort_key(). +DEFAULT_STR_SORT_KEY: Callable[[str], str] = ALPHABETICAL_SORT_KEY + + +def set_default_str_sort_key(sort_key: Callable[[str], str]) -> None: + """Set the application-wide sort key for strings.""" + global DEFAULT_STR_SORT_KEY # noqa: PLW0603 + DEFAULT_STR_SORT_KEY = sort_key diff --git a/docs/features/settings.md b/docs/features/settings.md index 02ee3399a..51f031274 100644 --- a/docs/features/settings.md +++ b/docs/features/settings.md @@ -68,14 +68,13 @@ If `True` the output is sent to `stdout` (which is often the screen but may be [redirected](./redirection.md#output-redirection-and-pipes)). The feedback output will be mixed in with and indistinguishable from output generated with `cmd2.Cmd.poutput`. -### max_completion_items +### max_completion_table_items -Maximum number of CompletionItems to display during tab completion. A CompletionItem is a special -kind of tab completion hint which displays both a value and description and uses one line for each -hint. Tab complete the `set` command for an example. +The maximum number of items to display in a completion table. A completion table is a special +kind of completion hint which displays details about items being completed. Tab complete +the `set` command for an example. -If the number of tab completion hints exceeds `max_completion_items`, then they will be displayed in -the typical columnized format and will not include the description text of the CompletionItem. +If the number of completion suggestions exceeds `max_completion_table_items`, then no table will appear. ### quiet diff --git a/examples/argparse_completion.py b/examples/argparse_completion.py index 8d2c3dca1..6b42aa5f0 100755 --- a/examples/argparse_completion.py +++ b/examples/argparse_completion.py @@ -110,7 +110,7 @@ def choices_arg_tokens(self, arg_tokens: dict[str, list[str]]) -> list[str]: '--completion_item', choices_provider=choices_completion_item, metavar="ITEM_ID", - descriptive_headers=["Description"], + table_header=["Description"], help="demonstrate use of CompletionItems", ) diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 8e069530d..dbc79b0a1 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -105,7 +105,7 @@ def do_pos_and_flag(self, args: argparse.Namespace) -> None: ############################################################################################################ STR_METAVAR = "HEADLESS" TUPLE_METAVAR = ('arg1', 'others') - CUSTOM_DESC_HEADERS = ("Custom Headers",) + CUSTOM_HEADER = ("Custom Header",) # tuples (for sake of immutability) used in our tests (there is a mix of sorted and unsorted on purpose) non_negative_num_choices = (1, 2, 3, 0.5, 22) @@ -149,7 +149,7 @@ def completion_item_method(self) -> list[CompletionItem]: "--desc_header", help='this arg has a descriptive header', choices_provider=completion_item_method, - descriptive_headers=CUSTOM_DESC_HEADERS, + table_header=CUSTOM_HEADER, ) choices_parser.add_argument( "--no_header", @@ -755,15 +755,15 @@ def test_completion_items(ac_app) -> None: @pytest.mark.parametrize( ('num_aliases', 'show_description'), [ - # The number of completion results determines if the description field of CompletionItems gets displayed - # in the tab completions. The count must be greater than 1 and less than ac_app.max_completion_items, + # The number of completion results determines if a completion table is displayed. + # The count must be greater than 1 and less than ac_app.max_completion_table_items, # which defaults to 50. (1, False), (5, True), (100, False), ], ) -def test_max_completion_items(ac_app, num_aliases, show_description) -> None: +def test_max_completion_table_items(ac_app, num_aliases, show_description) -> None: # Create aliases for i in range(num_aliases): run_cmd(ac_app, f'alias create fake_alias{i} help') @@ -951,28 +951,28 @@ def test_completion_items_arg_header(ac_app) -> None: assert ac_app.TUPLE_METAVAR[1].upper() in normalize(ac_app.formatted_completions)[0] -def test_completion_items_descriptive_headers(ac_app) -> None: +def test_completion_items_table_header(ac_app) -> None: from cmd2.argparse_completer import ( - DEFAULT_DESCRIPTIVE_HEADERS, + DEFAULT_TABLE_HEADER, ) - # This argument provided a descriptive header + # This argument provided a table header text = '' line = f'choices --desc_header {text}' endidx = len(line) begidx = endidx - len(text) complete_tester(text, line, begidx, endidx, ac_app) - assert ac_app.CUSTOM_DESC_HEADERS[0] in normalize(ac_app.formatted_completions)[0] + assert ac_app.CUSTOM_TABLE_HEADER[0] in normalize(ac_app.formatted_completions)[0] - # This argument did not provide a descriptive header, so it should be DEFAULT_DESCRIPTIVE_HEADERS + # This argument did not provide a table header, so it should be DEFAULT_TABLE_HEADER text = '' line = f'choices --no_header {text}' endidx = len(line) begidx = endidx - len(text) complete_tester(text, line, begidx, endidx, ac_app) - assert DEFAULT_DESCRIPTIVE_HEADERS[0] in normalize(ac_app.formatted_completions)[0] + assert DEFAULT_TABLE_HEADER[0] in normalize(ac_app.formatted_completions)[0] @pytest.mark.parametrize( @@ -1347,33 +1347,3 @@ def test_add_parser_custom_completer() -> None: custom_completer_parser = subparsers.add_parser(name="custom_completer", ap_completer_type=CustomCompleter) assert custom_completer_parser.get_ap_completer_type() is CustomCompleter # type: ignore[attr-defined] - - -def test_autcomp_fallback_to_flags_nargs0(ac_app) -> None: - """Test fallback to flags when a positional argument has nargs=0 (using manual patching)""" - from cmd2.argparse_completer import ( - ArgparseCompleter, - ) - - parser = Cmd2ArgumentParser() - # Add a positional argument - action = parser.add_argument('pos') - # Add a flag - parser.add_argument('-f', '--flag', action='store_true', help='a flag') - - # Manually change nargs to 0 AFTER adding it to bypass argparse validation during add_argument. - # This allows us to hit the fallback-to-flags logic in _handle_last_token where pos_arg_state.max is 0. - action.nargs = 0 - - ac = ArgparseCompleter(parser, ac_app) - - text = '' - line = 'cmd ' - endidx = len(line) - begidx = endidx - len(text) - tokens = [''] - - # This should hit the fallback to flags in _handle_last_token because pos has max=0 and count=0 - results = ac.complete(text, line, begidx, endidx, tokens) - - assert any(item == '-f' for item in results) From 94bbeb3a7027f8e8fe1a9d411da75595a606fb0b Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 13 Feb 2026 11:25:13 -0500 Subject: [PATCH 06/30] Replaced choices_provider and completer protocols with type aliases. --- cmd2/argparse_completer.py | 7 +-- cmd2/argparse_custom.py | 56 ++++++++---------------- cmd2/cmd2.py | 24 +++++----- cmd2/completion.py | 89 ++++++++++++++++++-------------------- cmd2/decorators.py | 6 +-- cmd2/utils.py | 8 ++-- 6 files changed, 80 insertions(+), 110 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 1c01125ad..abf4f88aa 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -33,7 +33,6 @@ ) from .command_definition import CommandSet from .completion import ( - ChoicesProviderFuncWithTokens, CompletionItem, Completions, all_display_numeric, @@ -761,11 +760,7 @@ def _complete_arg( if isinstance(raw_choices, ChoicesCallable): args, kwargs = self._prepare_callable_params(raw_choices, arg_state, text, consumed_arg_values, cmd_set) choices_func = raw_choices.choices_provider - - if isinstance(choices_func, ChoicesProviderFuncWithTokens): - all_choices = list(choices_func(*args, **kwargs)) - else: - all_choices = list(choices_func(*args)) + all_choices = list(choices_func(*args, **kwargs)) else: all_choices = raw_choices diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 083db8580..4481fadba 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -282,20 +282,14 @@ def get_items(self) -> list[CompletionItems]: from . import constants from . import rich_utils as ru from .completion import ( - ChoicesProviderFunc, - ChoicesProviderFuncBase, - ChoicesProviderFuncWithTokens, - CompleterFunc, - CompleterFuncBase, - CompleterFuncWithTokens, + ChoicesProviderUnbound, + CompleterUnbound, ) from .rich_utils import Cmd2RichArgparseConsole from .styles import Cmd2Style if TYPE_CHECKING: # pragma: no cover - from .argparse_completer import ( - ArgparseCompleter, - ) + from .argparse_completer import ArgparseCompleter def generate_range_error(range_min: int, range_max: float) -> str: @@ -376,7 +370,7 @@ class ChoicesCallable: def __init__( self, is_completer: bool, - to_call: CompleterFunc | ChoicesProviderFunc, + to_call: ChoicesProviderUnbound | CompleterUnbound, ) -> None: """Initialize the ChoiceCallable instance. @@ -385,35 +379,21 @@ def __init__( :param to_call: the callable object that will be called to provide choices for the argument. """ self.is_completer = is_completer - if is_completer: - if not isinstance(to_call, (CompleterFuncBase, CompleterFuncWithTokens)): # pragma: no cover - # runtime checking of Protocols do not currently check the parameters of a function. - raise ValueError( - 'With is_completer set to true, to_call must be either CompleterFunc, CompleterFuncWithTokens' - ) - elif not isinstance(to_call, (ChoicesProviderFuncBase, ChoicesProviderFuncWithTokens)): # pragma: no cover - # runtime checking of Protocols do not currently check the parameters of a function. - raise ValueError( - 'With is_completer set to false, to_call must be either: ' - 'ChoicesProviderFuncBase, ChoicesProviderFuncWithTokens' - ) self.to_call = to_call @property - def completer(self) -> CompleterFunc: - """Retreive the internal Completer function, first type checking to ensure it is the right type.""" - if not isinstance(self.to_call, (CompleterFuncBase, CompleterFuncWithTokens)): # pragma: no cover - # this should've been caught in the constructor, just a backup check - raise TypeError('Function is not a CompleterFunc') - return self.to_call + def choices_provider(self) -> ChoicesProviderUnbound: + """Retreive the internal choices_provider function.""" + if self.is_completer: + raise AttributeError("This instance is configured as a completer, not a choices_provider") + return cast(ChoicesProviderUnbound, self.to_call) @property - def choices_provider(self) -> ChoicesProviderFunc: - """Retreive the internal ChoicesProvider function, first type checking to ensure it is the right type.""" - if not isinstance(self.to_call, (ChoicesProviderFuncBase, ChoicesProviderFuncWithTokens)): # pragma: no cover - # this should've been caught in the constructor, just a backup check - raise TypeError('Function is not a ChoicesProviderFunc') - return self.to_call + def completer(self) -> CompleterUnbound: + """Retreive the internal completer function.""" + if not self.is_completer: + raise AttributeError("This instance is configured as a choices_provider, not a completer") + return cast(CompleterUnbound, self.to_call) ############################################################################################################ @@ -482,7 +462,7 @@ def _action_set_choices_callable(self: argparse.Action, choices_callable: Choice def _action_set_choices_provider( self: argparse.Action, - choices_provider: ChoicesProviderFunc, + choices_provider: ChoicesProviderUnbound, ) -> None: """Set choices_provider of an argparse Action. @@ -502,7 +482,7 @@ def _action_set_choices_provider( def _action_set_completer( self: argparse.Action, - completer: CompleterFunc, + completer: CompleterUnbound, ) -> None: """Set completer of an argparse Action. @@ -700,8 +680,8 @@ def _add_argument_wrapper( self: argparse._ActionsContainer, *args: Any, nargs: int | str | tuple[int] | tuple[int, int] | tuple[int, float] | None = None, - choices_provider: ChoicesProviderFunc | None = None, - completer: CompleterFunc | None = None, + choices_provider: ChoicesProviderUnbound | None = None, + completer: CompleterUnbound | None = None, suppress_tab_hint: bool = False, table_header: Sequence[str | Column] | None = None, **kwargs: Any, diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index c799db2f8..6ca46641f 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -100,8 +100,9 @@ ) from .completion import ( Choices, - ChoicesProviderFunc, - CompleterFunc, + ChoicesProviderUnbound, + CompleterBound, + CompleterUnbound, CompletionItem, Completions, Matchable, @@ -883,7 +884,7 @@ def _install_command_function(self, command_func_name: str, command_method: Comm setattr(self, command_func_name, command_method) - def _install_completer_function(self, cmd_name: str, cmd_completer: CompleterFunc) -> None: + def _install_completer_function(self, cmd_name: str, cmd_completer: CompleterBound) -> None: completer_func_name = COMPLETER_FUNC_PREFIX + cmd_name if hasattr(self, completer_func_name): @@ -1896,9 +1897,9 @@ def flag_based_complete( line: str, begidx: int, endidx: int, - flag_dict: dict[str, Iterable[Matchable] | CompleterFunc], + flag_dict: dict[str, Iterable[Matchable] | CompleterBound], *, - all_else: None | Iterable[Matchable] | CompleterFunc = None, + all_else: None | Iterable[Matchable] | CompleterBound = None, ) -> Completions: """Completes based on a particular flag preceding the token being completed. @@ -1944,9 +1945,9 @@ def index_based_complete( line: str, begidx: int, endidx: int, - index_dict: Mapping[int, Iterable[Matchable] | CompleterFunc], + index_dict: Mapping[int, Iterable[Matchable] | CompleterBound], *, - all_else: Iterable[Matchable] | CompleterFunc | None = None, + all_else: Iterable[Matchable] | CompleterBound | None = None, ) -> Completions: """Completes based on a fixed position in the input string. @@ -1972,7 +1973,7 @@ def index_based_complete( index = len(tokens) - 1 # Check if token is at an index in the dictionary - match_against: Iterable[Matchable] | CompleterFunc | None = index_dict.get(index, all_else) + match_against: Iterable[Matchable] | CompleterBound | None = index_dict.get(index, all_else) # Perform completion using a Iterable if isinstance(match_against, Iterable): @@ -2159,7 +2160,7 @@ def shell_cmd_complete( text, line, begidx, endidx, path_filter=lambda path: os.path.isdir(path) or os.access(path, os.X_OK) ) - def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, compfunc: CompleterFunc) -> Completions: + def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, compfunc: CompleterBound) -> Completions: """First completion function for all commands, called by complete(). It determines if it should complete for redirection (|, >, >>) or use the @@ -2304,6 +2305,7 @@ def _perform_completion( return Completions() # Determine the completer function to use for the command's argument + completer_func: CompleterBound if custom_settings is None: # Check if a macro was entered if command in self.macros: @@ -3314,8 +3316,8 @@ def read_input( completion_mode: utils.CompletionMode = utils.CompletionMode.NONE, preserve_quotes: bool = False, choices: Iterable[Any] | None = None, - choices_provider: ChoicesProviderFunc | None = None, - completer: CompleterFunc | None = None, + choices_provider: ChoicesProviderUnbound | None = None, + completer: CompleterUnbound | None = None, parser: argparse.ArgumentParser | None = None, ) -> str: """Read input from appropriate stdin value. diff --git a/cmd2/completion.py b/cmd2/completion.py index 069eb72fd..5d55f142e 100644 --- a/cmd2/completion.py +++ b/cmd2/completion.py @@ -3,6 +3,7 @@ import re import sys from collections.abc import ( + Callable, Iterator, Sequence, ) @@ -11,14 +12,17 @@ field, ) from typing import ( + TYPE_CHECKING, Any, - Protocol, TypeAlias, cast, overload, - runtime_checkable, ) +if TYPE_CHECKING: + from .cmd2 import Cmd + from .command_definition import CommandSet + if sys.version_info >= (3, 11): from typing import Self else: @@ -222,56 +226,45 @@ def all_display_numeric(items: Sequence[CompletionItem]) -> bool: return bool(items) and all(NUMERIC_RE.match(item.display) for item in items) -@runtime_checkable -class ChoicesProviderFuncBase(Protocol): - """Function that returns a list of choices in support of completion.""" - - def __call__(self) -> Choices: # pragma: no cover - """Enable instances to be called like functions.""" - - -@runtime_checkable -class ChoicesProviderFuncWithTokens(Protocol): - """Function that returns a list of choices in support of completion and accepts a dictionary of prior arguments.""" - - def __call__(self, *, arg_tokens: dict[str, list[str]] = {}) -> Choices: # pragma: no cover # noqa: B006 - """Enable instances to be called like functions.""" - - -ChoicesProviderFunc: TypeAlias = ChoicesProviderFuncBase | ChoicesProviderFuncWithTokens +############################################# +# choices_provider function types +############################################# +# Represents the parsed tokens from argparse during completion +ArgTokens: TypeAlias = dict[str, list[str]] -@runtime_checkable -class CompleterFuncBase(Protocol): - """Function to support completion with the provided state of the user prompt.""" - - def __call__( - self, - text: str, - line: str, - begidx: int, - endidx: int, - ) -> Completions: # pragma: no cover - """Enable instances to be called like functions.""" - - -@runtime_checkable -class CompleterFuncWithTokens(Protocol): - """Function to support completion with the provided state of the user prompt, accepts a dictionary of prior args.""" - - def __call__( - self, - text: str, - line: str, - begidx: int, - endidx: int, - *, - arg_tokens: dict[str, list[str]] = {}, # noqa: B006 - ) -> Completions: # pragma: no cover - """Enable instances to be called like functions.""" +# Unbound choices_provider function types used by argparse-based completion. +# These expect a Cmd or CommandSet instance as the first argument. +ChoicesProviderUnbound: TypeAlias = ( + # Basic: (self) -> Choices + Callable[["Cmd"], Choices] + | Callable[["CommandSet"], Choices] + | + # Context-aware: (self, arg_tokens) -> Choices + Callable[["Cmd", ArgTokens], Choices] + | Callable[["CommandSet", ArgTokens], Choices] +) +############################################# +# completer function types +############################################# + +# Unbound completer function types used by argparse-based completion. +# These expect a Cmd or CommandSet instance as the first argument. +CompleterUnbound: TypeAlias = ( + # Basic: (self, text, line, begidx, endidx) -> Completions + Callable[["Cmd", str, str, int, int], Completions] + | Callable[["CommandSet", str, str, int, int], Completions] + | + # Context-aware: (self, text, line, begidx, endidx, arg_tokens) -> Completions + Callable[["Cmd", str, str, int, int, ArgTokens], Completions] + | Callable[["CommandSet", str, str, int, int, ArgTokens], Completions] +) -CompleterFunc: TypeAlias = CompleterFuncBase | CompleterFuncWithTokens +# A bound completer used internally by cmd2 for basic completion logic. +# The 'self' argument is already tied to an instance and is omitted. +# Format: (text, line, begidx, endidx) -> Completions +CompleterBound: TypeAlias = Callable[[str, str, int, int], Completions] # Represents a type that can be matched against when completing. # Strings are matched directly while CompletionItems are matched diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 1dfa4d841..3783236c4 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -62,7 +62,7 @@ def cat_decorator(func: CommandFunc) -> CommandFunc: CommandParent = TypeVar('CommandParent', bound=Union['cmd2.Cmd', CommandSet]) -CommandParentType = TypeVar('CommandParentType', bound=type['cmd2.Cmd'] | type[CommandSet]) +CommandParentClass = TypeVar('CommandParentClass', bound=type['cmd2.Cmd'] | type[CommandSet]) RawCommandFuncOptionalBoolReturn: TypeAlias = Callable[[CommandParent, Statement | str], bool | None] @@ -222,7 +222,7 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: def with_argparser( parser: argparse.ArgumentParser # existing parser | Callable[[], argparse.ArgumentParser] # function or staticmethod - | Callable[[CommandParentType], argparse.ArgumentParser], # Cmd or CommandSet classmethod + | Callable[[CommandParentClass], argparse.ArgumentParser], # Cmd or CommandSet classmethod *, ns_provider: Callable[..., argparse.Namespace] | None = None, preserve_quotes: bool = False, @@ -356,7 +356,7 @@ def as_subcommand_to( subcommand: str, parser: argparse.ArgumentParser # existing parser | Callable[[], argparse.ArgumentParser] # function or staticmethod - | Callable[[CommandParentType], argparse.ArgumentParser], # Cmd or CommandSet classmethod + | Callable[[CommandParentClass], argparse.ArgumentParser], # Cmd or CommandSet classmethod *, help: str | None = None, # noqa: A002 aliases: list[str] | None = None, diff --git a/cmd2/utils.py b/cmd2/utils.py index 4fae8c5d6..121c5c21b 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -30,8 +30,8 @@ from . import string_utils as su from .completion import ( Choices, - ChoicesProviderFunc, - CompleterFunc, + ChoicesProviderUnbound, + CompleterUnbound, ) if TYPE_CHECKING: # pragma: no cover @@ -78,8 +78,8 @@ def __init__( settable_attrib_name: str | None = None, onchange_cb: Callable[[str, _T, _T], Any] | None = None, choices: Iterable[Any] | None = None, - choices_provider: ChoicesProviderFunc | None = None, - completer: CompleterFunc | None = None, + choices_provider: ChoicesProviderUnbound | None = None, + completer: CompleterUnbound | None = None, ) -> None: """Settable Initializer. From b0a31642e20c071d4e3a9f61a3e5b451d8ce50a4 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 14 Feb 2026 04:28:13 -0500 Subject: [PATCH 07/30] Corrected auto-quoting of completions. --- cmd2/cmd2.py | 83 ++++++++++++++-------------------------------- cmd2/completion.py | 16 +++++++++ cmd2/pt_utils.py | 42 +++++++++++++++++++++-- 3 files changed, 80 insertions(+), 61 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 6ca46641f..caf6afd85 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1854,7 +1854,7 @@ def delimiter_complete( if not basic_completions: return Completions() - match_strings = [item.text for item in basic_completions.items] + match_strings = [item.text for item in basic_completions] # Calculate what portion of the match we are completing common_prefix = os.path.commonprefix(match_strings) @@ -2370,7 +2370,7 @@ def _perform_completion( # Save the quote so we can add a matching closing quote later. completion_token_quote = raw_completion_token[0] - # prompt-toolkit still performs word breaks after a quote. Therefore, something like quoted search + # Cmd2Completer still performs word breaks after a quote. Therefore, something like quoted search # text with a space would have resulted in begidx pointing to the middle of the token we # we want to complete. Figure out where that token actually begins and save the beginning # portion of it that was not part of the text prompt-toolkit gave us. We will remove it from the @@ -2391,73 +2391,31 @@ def _perform_completion( if not completions: return Completions() - # Create a mutable list to possibly make changes to items - working_items = list(completions.items) + _add_opening_quote = False + _quote_char = completion_token_quote # Check if we need to add an opening quote if not completion_token_quote: - add_quote = False + current_texts = [item.text for item in completions] - # Extract the raw text and display strings for quote detection - current_texts = [item.text for item in working_items] + if any(' ' in text for text in current_texts): + _add_opening_quote = True - # For delimited matches, check for a space in the common text prefix - # as well as in the display segments. - if completions.is_delimited: - common_prefix = os.path.commonprefix(current_texts) - current_displays = [item.display for item in working_items] - - if ' ' in common_prefix or any(' ' in display for display in current_displays): - add_quote = True - - # Otherwise check if any text match has a space. - elif any(' ' in text for text in current_texts): - add_quote = True - - if add_quote: # Determine best quote (single vs double) based on text content - completion_token_quote = "'" if any('"' in t for t in current_texts) else '"' - working_items = [ - dataclasses.replace( - item, - text=completion_token_quote + item.text, - ) - for item in working_items - ] + _quote_char = "'" if any('"' in t for t in current_texts) else '"' # Check if we need to remove text from the beginning of completions elif text_to_remove: - working_items = [ + new_items = [ dataclasses.replace( item, text=item.text.replace(text_to_remove, '', 1), ) - for item in working_items + for item in completions ] + completions = dataclasses.replace(completions, items=new_items) - # Handle closing quote and trailing space - if len(working_items) == 1 and completions.allow_finalization: - final_item = working_items[0] - new_text = final_item.text - - # Append the matching quote if we started one - if completion_token_quote: - new_text += completion_token_quote - - # Append a space if the cursor is at the end of the line - if endidx == len(line): - new_text += ' ' - - # Update the item if the text changed - if new_text != final_item.text: - working_items = [dataclasses.replace(final_item, text=new_text)] - - # Convert back to tuple for comparison with the original frozen tuple - final_items_tuple = tuple(working_items) - if final_items_tuple != completions.items: - completions = dataclasses.replace(completions, items=final_items_tuple) - - return completions + return dataclasses.replace(completions, _add_opening_quote=_add_opening_quote, _quote_char=_quote_char) def complete( self, @@ -2525,19 +2483,26 @@ def complete( completions = self._perform_completion(text, line, begidx, endidx, custom_settings) - # Check if we need to restore a shortcut in the completions - # so it doesn't get erased from the command line + # Check if we need to restore a shortcut in the completion text + # so it doesn't get erased from the command line. if completions and shortcut_to_restore: new_items = [ dataclasses.replace( item, text=shortcut_to_restore + item.text, ) - for item in completions.items + for item in completions ] - completions = dataclasses.replace(completions, items=new_items) - # Swap between COLUMN and MULTI_COLUMN style based on the number of matches if not using READLINE_LIKE + # Update items and set _quote_from_offset so that any auto-inserted + # opening quote is placed after the shortcut. + completions = dataclasses.replace( + completions, + items=new_items, + _search_text_offset=len(shortcut_to_restore), + ) + + # Swap between COLUMN and MULTI_COLUMN style based on the number of matches. if len(completions) > self.max_column_completion_results: self.session.complete_style = CompleteStyle.MULTI_COLUMN else: diff --git a/cmd2/completion.py b/cmd2/completion.py index 5d55f142e..8f0b8d0fe 100644 --- a/cmd2/completion.py +++ b/cmd2/completion.py @@ -220,6 +220,22 @@ class Completions(CompletionResultsBase): # specialized quoting logic. is_delimited: bool = False + ##################################################################### + # The following fields are used internally by cmd2 to handle + # automatic quoting and are not intended for user modification. + ##################################################################### + + # Whether to add an opening quote to the matches. + _add_opening_quote: bool = False + + # The starting index of the user-provided search text within a full match. + # This accounts for leading shortcuts (e.g., in '?cmd', the offset is 1). + # Used to ensure opening quotes are inserted after the shortcut rather than before it. + _search_text_offset: int = 0 + + # The quote character to use if adding an opening or closing quote to the matches. + _quote_char: str = "" + def all_display_numeric(items: Sequence[CompletionItem]) -> bool: """Return True if items is non-empty and every item.display is a numeric string.""" diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index 5cda30cb6..cfd4dda04 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -92,12 +92,50 @@ def get_completions(self, document: Document, _complete_event: object) -> Iterab if not completions: return + # The length of the user's input minus any shortcut. + search_text_length = len(text) - completions._search_text_offset + + # If matches require quoting but the word isn't quoted yet, we insert the + # opening quote directly into the buffer. We do this because if any completions + # change text before the cursor (like prepending a quote), prompt-toolkit will + # not return a common prefix to the command line. By modifying the buffer + # and returning early, we trigger a new completion cycle where the quote + # is already present, allowing for proper common prefix calculation. + if completions._add_opening_quote and search_text_length > 0: + buffer = self.cmd_app.session.app.current_buffer + + buffer.cursor_left(search_text_length) + buffer.insert_text(completions._quote_char) + buffer.cursor_right(search_text_length) + return + # Return the completions - for item in completions.items: + for item in completions: # Set offset to the start of the current word to overwrite it with the completion start_position = -len(text) + match_text = item.text + + # If we need a quote but didn't interrupt (because text was empty), + # prepend the quote here so it's included in the insertion. + if completions._add_opening_quote: + match_text = ( + match_text[: completions._search_text_offset] + + completions._quote_char + + match_text[completions._search_text_offset :] + ) + + # Finalize if there's only one match + if len(completions) == 1 and completions.allow_finalization: + # Close any open quote + if completions._quote_char: + match_text += completions._quote_char + + # Add trailing space if the cursor is at the end of the line + if endidx == len(line): + match_text += " " + yield Completion( - item.text, + match_text, start_position=start_position, display=item.display, display_meta=item.display_meta, From fb139642dbf899f71ae64e7df28b5c47a76446e2 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 14 Feb 2026 04:46:44 -0500 Subject: [PATCH 08/30] Fixed issue where argparse flags all displayed the same name in the completion menu. --- cmd2/argparse_completer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index abf4f88aa..fe3a4ebd8 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -559,7 +559,7 @@ def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, used_f for item in matched_flags.items: action = self._flag_to_action[item.text] - matched_actions.setdefault(action, []).append(flag) + matched_actions.setdefault(action, []).append(item.text) # For completion suggestions, group matched flags by action items: list[CompletionItem] = [] From 1d125d2d9b7fe2758b07134350fa0e42c9a6e469 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 14 Feb 2026 05:39:15 -0500 Subject: [PATCH 09/30] Fixed sort order of argparse flags in completion menu. --- cmd2/argparse_completer.py | 5 ++++- cmd2/cmd2.py | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index fe3a4ebd8..9c4f79176 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -554,9 +554,12 @@ def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, used_f match_against.append(flag) # Build a dictionary linking actions with their matched flag names - matched_flags = self._cmd2_app.basic_complete(text, line, begidx, endidx, match_against) matched_actions: dict[argparse.Action, list[str]] = {} + # Keep flags sorted in the order provided by argparse so our completion + # suggestions display the same as argparse help text. + matched_flags = self._cmd2_app.basic_complete(text, line, begidx, endidx, match_against, sort=False) + for item in matched_flags.items: action = self._flag_to_action[item.text] matched_actions.setdefault(action, []).append(item.text) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index caf6afd85..530db4ed0 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1788,6 +1788,8 @@ def basic_complete( begidx: int, # noqa: ARG002 endidx: int, # noqa: ARG002 match_against: Iterable[Matchable], + *, + sort: bool = True, ) -> Completions: """Perform completion without considering line contents or cursor position. @@ -1798,6 +1800,8 @@ def basic_complete( :param begidx: the beginning index of the prefix text :param endidx: the ending index of the prefix text :param match_against: the items being matched against + :param sort: if True, then results will be sorted. If False, then items will + be in the same order they appeared in match_against. :return: a Completions object """ matches: list[CompletionItem] = [] @@ -1807,7 +1811,7 @@ def basic_complete( if candidate.startswith(text): matches.append(item if isinstance(item, CompletionItem) else CompletionItem(item)) - return Completions(items=matches) + return Completions(items=matches, is_sorted=not sort) def delimiter_complete( self, From 80ff94482640954c928168125187adfec89ee28f Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 14 Feb 2026 11:54:06 -0500 Subject: [PATCH 10/30] Updated test_cmd2.py for the new completion API. --- cmd2/__init__.py | 8 ++- tests/conftest.py | 41 ------------- tests/test_cmd2.py | 143 ++++++++++++++++----------------------------- 3 files changed, 57 insertions(+), 135 deletions(-) diff --git a/cmd2/__init__.py b/cmd2/__init__.py index a4d9e9fd1..a87303daa 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -24,7 +24,11 @@ CommandSet, with_default_category, ) -from .completion import CompletionItem +from .completion import ( + Choices, + CompletionItem, + Completions, +) from .constants import ( COMMAND_NAME, DEFAULT_SHORTCUTS, @@ -72,7 +76,9 @@ # Colors "Color", # Completion + 'Choices', 'CompletionItem', + 'Completions', # Decorators 'with_argument_list', 'with_argparser', diff --git a/tests/conftest.py b/tests/conftest.py index 63d877bc7..d47c1b5de 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -118,47 +118,6 @@ def cmd_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: odd_file_names = ['nothingweird', 'has spaces', '"is_double_quoted"', "'is_single_quoted'"] -def complete_tester(text: str, line: str, begidx: int, endidx: int, app: cmd2.Cmd) -> str | None: - """This is a convenience function to test cmd2.complete() since - in a unit test environment there is no actual console prompt-toolkit - is monitoring. Therefore we use mock to provide prompt-toolkit data - to complete(). - - :param text: the string prefix we are attempting to match - :param line: the current input line with leading whitespace removed - :param begidx: the beginning index of the prefix text - :param endidx: the ending index of the prefix text - :param app: the cmd2 app that will run completions - :return: The first matched string or None if there are no matches - Matches are stored in app.completion_matches - These matches also have been sorted by complete() - """ - - def get_line() -> str: - return line - - def get_begidx() -> int: - return begidx - - def get_endidx() -> int: - return endidx - - # Run the prompt-toolkit tab completion function with mocks in place - res = app.complete(text, line, begidx, endidx) - - # If the completion resulted in a hint being set, then print it now - # so that it can be captured by tests using capsys. - if app.completion_hint: - print(app.completion_hint) - - # If the completion resulted in a header being set (e.g. CompletionError), then print it now - # so that it can be captured by tests using capsys. - if app.completion_header: - print(app.completion_header) - - return res - - def find_subcommand(action: argparse.ArgumentParser, subcmd_names: list[str]) -> argparse.ArgumentParser: if not subcmd_names: return action diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 0e3c649ec..781c85ff5 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -21,6 +21,7 @@ Cmd2Style, Color, CommandSet, + Completions, RichPrintKwargs, clipboard, constants, @@ -34,7 +35,6 @@ from .conftest import ( SHORTCUTS_TXT, - complete_tester, normalize, odd_file_names, run_cmd, @@ -2269,33 +2269,37 @@ def test_broken_pipe_error(outsim_app, monkeypatch, capsys): ] -def test_get_alias_completion_items(base_app) -> None: +def test_get_alias_choices(base_app: cmd2.Cmd) -> None: run_cmd(base_app, 'alias create fake run_pyscript') run_cmd(base_app, 'alias create ls !ls -hal') - results = base_app._get_alias_completion_items() - assert len(results) == len(base_app.aliases) + choices = base_app._get_alias_choices() - for cur_res in results: - assert cur_res in base_app.aliases - # Strip trailing spaces from table output - assert cur_res.descriptive_data[0].rstrip() == base_app.aliases[cur_res] + aliases = base_app.aliases + assert len(choices) == len(aliases) + for cur_choice in choices: + assert cur_choice.text in aliases + assert cur_choice.display_meta == aliases[cur_choice.text] + assert cur_choice.table_row == (aliases[cur_choice.text],) -def test_get_macro_completion_items(base_app) -> None: + +def test_get_macro_choices(base_app: cmd2.Cmd) -> None: run_cmd(base_app, 'macro create foo !echo foo') run_cmd(base_app, 'macro create bar !echo bar') - results = base_app._get_macro_completion_items() - assert len(results) == len(base_app.macros) + choices = base_app._get_macro_choices() + + macros = base_app.macros + assert len(choices) == len(macros) - for cur_res in results: - assert cur_res in base_app.macros - # Strip trailing spaces from table output - assert cur_res.descriptive_data[0].rstrip() == base_app.macros[cur_res].value + for cur_choice in choices: + assert cur_choice.text in macros + assert cur_choice.display_meta == macros[cur_choice.text].value + assert cur_choice.table_row == (macros[cur_choice.text].value,) -def test_get_commands_aliases_and_macros_for_completion(base_app) -> None: +def test_get_commands_aliases_and_macros_choices(base_app: cmd2.Cmd) -> None: # Add an alias and a macro run_cmd(base_app, 'alias create fake_alias help') run_cmd(base_app, 'macro create fake_macro !echo macro') @@ -2308,50 +2312,46 @@ def do_no_doc(self, arg): base_app.do_no_doc = types.MethodType(do_no_doc, base_app) - results = base_app._get_commands_aliases_and_macros_for_completion() + choices = base_app._get_commands_aliases_and_macros_choices() # All visible commands + our new command + alias + macro expected_count = len(base_app.get_visible_commands()) + len(base_app.aliases) + len(base_app.macros) - assert len(results) == expected_count + assert len(choices) == expected_count # Verify alias - alias_item = next((item for item in results if item == 'fake_alias'), None) + alias_item = next((item for item in choices if item == 'fake_alias'), None) assert alias_item is not None - assert alias_item.descriptive_data[0] == "Alias for: help" + assert alias_item.display_meta == "Alias for: help" # Verify macro - macro_item = next((item for item in results if item == 'fake_macro'), None) + macro_item = next((item for item in choices if item == 'fake_macro'), None) assert macro_item is not None - assert macro_item.descriptive_data[0] == "Macro: !echo macro" + assert macro_item.display_meta == "Macro: !echo macro" # Verify command with docstring (help) - help_item = next((item for item in results if item == 'help'), None) + help_item = next((item for item in choices if item == 'help'), None) assert help_item is not None # First line of help docstring - assert "List available commands" in help_item.descriptive_data[0] + assert "List available commands" in help_item.display_meta # Verify command without docstring - no_doc_item = next((item for item in results if item == 'no_doc'), None) + no_doc_item = next((item for item in choices if item == 'no_doc'), None) assert no_doc_item is not None - assert no_doc_item.descriptive_data[0] == "" + assert no_doc_item.display_meta == "" -def test_get_settable_completion_items(base_app) -> None: - results = base_app._get_settable_completion_items() - assert len(results) == len(base_app.settables) +def test_get_settable_choices(base_app: cmd2.Cmd) -> None: + choices = base_app._get_settable_choices() + assert len(choices) == len(base_app.settables) - for cur_res in results: - cur_settable = base_app.settables.get(cur_res) + for cur_choice in choices: + cur_settable = base_app.settables.get(cur_choice.text) assert cur_settable is not None - # These CompletionItem descriptions are a two column table (Settable Value and Settable Description) - # First check if the description text starts with the value str_value = str(cur_settable.value) - assert cur_res.descriptive_data[0].startswith(str_value) - - # The second column is likely to have wrapped long text. So we will just examine the - # first couple characters to look for the Settable's description. - assert cur_settable.description[0:10] in cur_res.descriptive_data[1] + assert cur_choice.display_meta == str_value + assert cur_choice.table_row[0] == str_value + assert cur_choice.table_row[1] == cur_settable.description def test_completion_supported(base_app) -> None: @@ -3296,8 +3296,8 @@ def do_has_helper_funcs(self, arg) -> None: def help_has_helper_funcs(self) -> None: self.poutput('Help for has_helper_funcs') - def complete_has_helper_funcs(self, *args): - return ['result'] + def complete_has_helper_funcs(self, *args) -> Completions: + return Completions.from_strings(['result']) @cmd2.with_category(category_name) def do_has_no_helper_funcs(self, arg) -> None: @@ -3316,11 +3316,11 @@ def do_new_command(self, arg) -> None: @pytest.fixture -def disable_commands_app(): +def disable_commands_app() -> DisableCommandsApp: return DisableCommandsApp() -def test_disable_and_enable_category(disable_commands_app) -> None: +def test_disable_and_enable_category(disable_commands_app: DisableCommandsApp) -> None: ########################################################################## # Disable the category ########################################################################## @@ -3346,16 +3346,16 @@ def test_disable_and_enable_category(disable_commands_app) -> None: endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, disable_commands_app) - assert first_match is None + completions = disable_commands_app.complete(text, line, begidx, endidx) + assert not completions text = '' line = f'has_no_helper_funcs {text}' endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, disable_commands_app) - assert first_match is None + completions = disable_commands_app.complete(text, line, begidx, endidx) + assert not completions # Make sure both commands are invisible visible_commands = disable_commands_app.get_visible_commands() @@ -3390,9 +3390,8 @@ def test_disable_and_enable_category(disable_commands_app) -> None: endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, disable_commands_app) - assert first_match is not None - assert disable_commands_app.completion_matches == ['result '] + completions = disable_commands_app.complete(text, line, begidx, endidx) + assert completions[0].text == "result" # has_no_helper_funcs had no completer originally, so there should be no results text = '' @@ -3400,8 +3399,8 @@ def test_disable_and_enable_category(disable_commands_app) -> None: endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, disable_commands_app) - assert first_match is None + completions = disable_commands_app.complete(text, line, begidx, endidx) + assert not completions # Make sure both commands are visible visible_commands = disable_commands_app.get_visible_commands() @@ -3927,45 +3926,3 @@ def test_auto_suggest_default(): assert app.auto_suggest is not None assert isinstance(app.auto_suggest, AutoSuggestFromHistory) assert app.session.auto_suggest is app.auto_suggest - - -def test_completion_quoting_with_spaces_and_no_common_prefix(tmp_path): - """Test that completion results with spaces are quoted even if there is no common prefix.""" - # Create files in a temporary directory - has_space_dir = tmp_path / "has space" - has_space_dir.mkdir() - foo_file = tmp_path / "foo.txt" - foo_file.write_text("content") - - # Change CWD to the temporary directory - cwd = os.getcwd() - os.chdir(tmp_path) - - try: - # Define a custom command with path_complete - class PathApp(cmd2.Cmd): - def do_test_path(self, _): - pass - - def complete_test_path(self, text, line, begidx, endidx): - return self.path_complete(text, line, begidx, endidx) - - app = PathApp() - - text = '' - line = f'test_path {text}' - endidx = len(line) - begidx = endidx - len(text) - - complete_tester(text, line, begidx, endidx, app) - - matches = app.completion_matches - - # Find the match for our directory - has_space_match = next((m for m in matches if "has space" in m), None) - assert has_space_match is not None - - # Check if it is quoted. - assert has_space_match.startswith(('"', "'")) - finally: - os.chdir(cwd) From 67d4a66f52c7734d24984a631fc8eb22e673c389 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 14 Feb 2026 16:25:35 -0500 Subject: [PATCH 11/30] Renamed CompletionResultsBase.from_strings() to from_values(). Added CompletionResultsBase.to_strings(). --- cmd2/argparse_completer.py | 36 +++++++++++++++++++----------------- cmd2/cmd2.py | 10 +++++----- cmd2/completion.py | 16 ++++++++++------ cmd2/utils.py | 2 +- tests/test_cmd2.py | 2 +- 5 files changed, 36 insertions(+), 30 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 9c4f79176..208153f1f 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -6,7 +6,10 @@ import argparse import dataclasses import inspect -from collections import deque +from collections import ( + defaultdict, + deque, +) from collections.abc import Sequence from typing import ( IO, @@ -247,7 +250,7 @@ def complete( used_flags: set[str] = set() # Keeps track of arguments we've seen and any tokens they consumed - consumed_arg_values: dict[str, list[str]] = {} # dict(arg_name -> list[tokens]) + consumed_arg_values: dict[str, list[str]] = defaultdict(list) # Completed mutually exclusive groups completed_mutex_groups: dict[argparse._MutuallyExclusiveGroup, argparse.Action] = {} @@ -255,7 +258,6 @@ def complete( def consume_argument(arg_state: _ArgumentState, arg_token: str) -> None: """Consume token as an argument.""" arg_state.count += 1 - consumed_arg_values.setdefault(arg_state.action.dest, []) consumed_arg_values[arg_state.action.dest].append(arg_token) ############################################################################################# @@ -315,7 +317,11 @@ def consume_argument(arg_state: _ArgumentState, arg_token: str) -> None: if action is not None: self._update_mutex_groups(action, completed_mutex_groups, used_flags, remaining_positionals) - if isinstance( + + # Check if the action type allows the same flag to be provided multiple times. + # Reusable actions (append, count, extend) preserve their history so the + # completion logic knows which values have already been 'consumed'. + if not isinstance( action, ( argparse._AppendAction, @@ -324,16 +330,12 @@ def consume_argument(arg_state: _ArgumentState, arg_token: str) -> None: argparse._ExtendAction, ), ): - # Flags with actions set to append, append_const, count, and extend can be reused. - # Therefore don't erase any tokens already consumed for this flag. - consumed_arg_values.setdefault(action.dest, []) - else: - # This flag is not reusable, so mark that we've seen it + # For standard 'overwrite' actions (e.g., --store), providing the flag + # again resets its state. We mark the flags as 'used' to potentially + # filter them from future completion results and clear any previously + # recorded values for this destination. used_flags.update(action.option_strings) - - # It's possible we already have consumed values for this flag if it was used - # earlier in the command line. Reset them now for this use of it. - consumed_arg_values[action.dest] = [] + consumed_arg_values[action.dest].clear() new_arg_state = _ArgumentState(action) @@ -554,15 +556,15 @@ def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, used_f match_against.append(flag) # Build a dictionary linking actions with their matched flag names - matched_actions: dict[argparse.Action, list[str]] = {} + matched_actions: dict[argparse.Action, list[str]] = defaultdict(list) # Keep flags sorted in the order provided by argparse so our completion # suggestions display the same as argparse help text. matched_flags = self._cmd2_app.basic_complete(text, line, begidx, endidx, match_against, sort=False) - for item in matched_flags.items: - action = self._flag_to_action[item.text] - matched_actions.setdefault(action, []).append(item.text) + for flag in matched_flags.to_strings(): + action = self._flag_to_action[flag] + matched_actions[action].append(flag) # For completion suggestions, group matched flags by action items: list[CompletionItem] = [] diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 530db4ed0..720fcf671 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1178,7 +1178,7 @@ def build_settables(self) -> None: def get_allow_style_choices(_cli_self: Cmd) -> Choices: """Complete allow_style values.""" styles = [val.name.lower() for val in ru.AllowStyle] - return Choices.from_strings(styles) + return Choices.from_values(styles) def allow_style_type(value: str) -> ru.AllowStyle: """Convert a string value into an ru.AllowStyle.""" @@ -1858,7 +1858,7 @@ def delimiter_complete( if not basic_completions: return Completions() - match_strings = [item.text for item in basic_completions] + match_strings = basic_completions.to_strings() # Calculate what portion of the match we are completing common_prefix = os.path.commonprefix(match_strings) @@ -2400,13 +2400,13 @@ def _perform_completion( # Check if we need to add an opening quote if not completion_token_quote: - current_texts = [item.text for item in completions] + matches = completions.to_strings() - if any(' ' in text for text in current_texts): + if any(' ' in match for match in matches): _add_opening_quote = True # Determine best quote (single vs double) based on text content - _quote_char = "'" if any('"' in t for t in current_texts) else '"' + _quote_char = "'" if any('"' in t for t in matches) else '"' # Check if we need to remove text from the beginning of completions elif text_to_remove: diff --git a/cmd2/completion.py b/cmd2/completion.py index 8f0b8d0fe..5dd55553b 100644 --- a/cmd2/completion.py +++ b/cmd2/completion.py @@ -53,7 +53,7 @@ class CompletionItem: """A single completion result.""" - # The underlying object this completion represents (e.g., a Path, Enum, or int). + # The underlying object this completion represents (e.g., str, int, Path). # This is used to support argparse choices validation. value: Any = field(kw_only=False) @@ -148,15 +148,19 @@ def __post_init__(self) -> None: object.__setattr__(self, "items", tuple(unique_items)) @classmethod - def from_strings(cls, strings: Sequence[str], *, is_sorted: bool = False) -> Self: - """Create a completion results instance from a sequence of strings. + def from_values(cls, values: Sequence[str], *, is_sorted: bool = False) -> Self: + """Create a completion results instance from a sequence of arbitrary objects. - :param strings: the raw strings to be converted into CompletionItems. - :param is_sorted: whether the strings are already in the desired order. + :param values: the raw objects (e.g. strs, ints, Paths) to be converted into CompletionItems. + :param is_sorted: whether the values are already in the desired order. """ - items = [CompletionItem(s) for s in strings] + items = [CompletionItem(value=v) for v in values] return cls(items=items, is_sorted=is_sorted) + def to_strings(self) -> tuple[str, ...]: + """Return a tuple of the completion strings (the 'text' field of each item).""" + return tuple(item.text for item in self.items) + # --- Sequence Protocol Functions --- def __bool__(self) -> bool: diff --git a/cmd2/utils.py b/cmd2/utils.py index 121c5c21b..342dedec7 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -118,7 +118,7 @@ def __init__( def get_bool_choices(_cmd2_self: "CommandParent") -> Choices: """Tab complete lowercase boolean values.""" - return Choices.from_strings(['true', 'false']) + return Choices.from_values(['true', 'false']) val_type = to_bool choices_provider = get_bool_choices diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 781c85ff5..d5256661f 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -3297,7 +3297,7 @@ def help_has_helper_funcs(self) -> None: self.poutput('Help for has_helper_funcs') def complete_has_helper_funcs(self, *args) -> Completions: - return Completions.from_strings(['result']) + return Completions.from_values(['result']) @cmd2.with_category(category_name) def do_has_no_helper_funcs(self, arg) -> None: From bad78a41affc6f7f0dbc2484c4906a835d7f1e39 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 14 Feb 2026 17:01:51 -0500 Subject: [PATCH 12/30] Move completion exception handling back to Cmd.complete(). --- cmd2/cmd2.py | 157 +++++++++++++++++++++++++-------------------- cmd2/completion.py | 3 + cmd2/pt_utils.py | 27 ++------ 3 files changed, 96 insertions(+), 91 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 720fcf671..bd518f5e5 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1488,7 +1488,7 @@ def format_exception(self, exception: BaseException) -> str: ) final_msg.append(help_msg) - console.print(final_msg) + console.print(final_msg, end="") return capture.get() @@ -1506,7 +1506,7 @@ def pexcept( method and still call `super()` without encountering unexpected keyword argument errors. """ formatted_exception = self.format_exception(exception) - self.print_to(sys.stderr, formatted_exception) + self.print_to(sys.stderr, formatted_exception + "\n") def pfeedback( self, @@ -2437,82 +2437,99 @@ def complete( :param endidx: ending index of text :param custom_settings: used when not completing the main command line :return: a Completions object - :raises CompletionError: if a completion-related exception occurs - :raises Exception: for any unhandled underlying processing errors """ - # Check if we are completing a multiline command - if self._at_continuation_prompt: - # lstrip and prepend the previously typed portion of this multiline command - lstripped_previous = self._multiline_in_progress.lstrip() - line = lstripped_previous + line - - # Increment the indexes to account for the prepended text - begidx = len(lstripped_previous) + begidx - endidx = len(lstripped_previous) + endidx - else: - # lstrip the original line - orig_line = line - line = orig_line.lstrip() - num_stripped = len(orig_line) - len(line) - - # Calculate new indexes for the stripped line. If the cursor is at a position before the end of a - # line of spaces, then the following math could result in negative indexes. Enforce a max of 0. - begidx = max(begidx - num_stripped, 0) - endidx = max(endidx - num_stripped, 0) - - # Shortcuts are not word break characters when completing. Therefore, shortcuts become part - # of the text variable if there isn't a word break, like a space, after it. We need to remove it - # from text and update the indexes. This only applies if we are at the beginning of the command line. - shortcut_to_restore = '' - if begidx == 0 and custom_settings is None: - for shortcut, _ in self.statement_parser.shortcuts: - if text.startswith(shortcut): - # Save the shortcut to restore later - shortcut_to_restore = shortcut - - # Adjust text and where it begins - text = text[len(shortcut_to_restore) :] - begidx += len(shortcut_to_restore) - break + try: + # Check if we are completing a multiline command + if self._at_continuation_prompt: + # lstrip and prepend the previously typed portion of this multiline command + lstripped_previous = self._multiline_in_progress.lstrip() + line = lstripped_previous + line + + # Increment the indexes to account for the prepended text + begidx = len(lstripped_previous) + begidx + endidx = len(lstripped_previous) + endidx else: - # No shortcut was found. Complete the command token. - parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(add_help=False) - parser.add_argument( - 'command', - metavar="COMMAND", - help="command, alias, or macro name", - choices=self._get_commands_aliases_and_macros_choices(), - ) - custom_settings = utils.CustomCompletionSettings(parser) + # lstrip the original line + orig_line = line + line = orig_line.lstrip() + num_stripped = len(orig_line) - len(line) + + # Calculate new indexes for the stripped line. If the cursor is at a position before the end of a + # line of spaces, then the following math could result in negative indexes. Enforce a max of 0. + begidx = max(begidx - num_stripped, 0) + endidx = max(endidx - num_stripped, 0) + + # Shortcuts are not word break characters when completing. Therefore, shortcuts become part + # of the text variable if there isn't a word break, like a space, after it. We need to remove it + # from text and update the indexes. This only applies if we are at the beginning of the command line. + shortcut_to_restore = '' + if begidx == 0 and custom_settings is None: + for shortcut, _ in self.statement_parser.shortcuts: + if text.startswith(shortcut): + # Save the shortcut to restore later + shortcut_to_restore = shortcut + + # Adjust text and where it begins + text = text[len(shortcut_to_restore) :] + begidx += len(shortcut_to_restore) + break + else: + # No shortcut was found. Complete the command token. + parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(add_help=False) + parser.add_argument( + 'command', + metavar="COMMAND", + help="command, alias, or macro name", + choices=self._get_commands_aliases_and_macros_choices(), + ) + custom_settings = utils.CustomCompletionSettings(parser) - completions = self._perform_completion(text, line, begidx, endidx, custom_settings) + completions = self._perform_completion(text, line, begidx, endidx, custom_settings) - # Check if we need to restore a shortcut in the completion text - # so it doesn't get erased from the command line. - if completions and shortcut_to_restore: - new_items = [ - dataclasses.replace( - item, - text=shortcut_to_restore + item.text, + # Check if we need to restore a shortcut in the completion text + # so it doesn't get erased from the command line. + if completions and shortcut_to_restore: + new_items = [ + dataclasses.replace( + item, + text=shortcut_to_restore + item.text, + ) + for item in completions + ] + + # Update items and set _quote_from_offset so that any auto-inserted + # opening quote is placed after the shortcut. + completions = dataclasses.replace( + completions, + items=new_items, + _search_text_offset=len(shortcut_to_restore), ) - for item in completions - ] - # Update items and set _quote_from_offset so that any auto-inserted - # opening quote is placed after the shortcut. - completions = dataclasses.replace( - completions, - items=new_items, - _search_text_offset=len(shortcut_to_restore), - ) + # Swap between COLUMN and MULTI_COLUMN style based on the number of matches. + if len(completions) > self.max_column_completion_results: + self.session.complete_style = CompleteStyle.MULTI_COLUMN + else: + self.session.complete_style = CompleteStyle.COLUMN - # Swap between COLUMN and MULTI_COLUMN style based on the number of matches. - if len(completions) > self.max_column_completion_results: - self.session.complete_style = CompleteStyle.MULTI_COLUMN - else: - self.session.complete_style = CompleteStyle.COLUMN + return completions # noqa: TRY300 + + except CompletionError as ex: + err_str = str(ex) + completion_error = "" - return completions + # Don't display anything if the error is blank (e.g. _NoResultsError for an argument which supresses hints) + if err_str: + console = ru.Cmd2GeneralConsole() + with console.capture() as capture: + console.print( + Text(err_str, style=Cmd2Style.ERROR if ex.apply_style else ""), + end="", + ) + completion_error = capture.get() + return Completions(completion_error=completion_error) + except Exception as ex: # noqa: BLE001 + formatted_exception = self.format_exception(ex) + return Completions(completion_error=formatted_exception) def in_script(self) -> bool: """Return whether a text script is running.""" diff --git a/cmd2/completion.py b/cmd2/completion.py index 5dd55553b..7cb246bbd 100644 --- a/cmd2/completion.py +++ b/cmd2/completion.py @@ -207,6 +207,9 @@ class Completions(CompletionResultsBase): # An optional hint which prints above completion suggestions completion_hint: str = "" + # Optional message to display if an error occurs during completion + completion_error: str = "" + # An optional table string populated by the argparse completer completion_table: str = "" diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index cfd4dda04..6f4a082f7 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -16,15 +16,11 @@ from prompt_toolkit.formatted_text import ANSI from prompt_toolkit.history import History from prompt_toolkit.lexers import Lexer -from rich.text import Text from . import ( constants, utils, ) -from . import rich_utils as ru -from .exceptions import CompletionError -from .styles import Cmd2Style if TYPE_CHECKING: from .cmd2 import Cmd @@ -62,23 +58,12 @@ def get_completions(self, document: Document, _complete_event: object) -> Iterab endidx = cursor_pos text = line[begidx:endidx] - try: - completions = self.cmd_app.complete( - text, line=line, begidx=begidx, endidx=endidx, custom_settings=self.custom_settings - ) - except CompletionError as ex: - # Don't print unless error has length - err_str = str(ex) - if err_str: - general_console = ru.Cmd2GeneralConsole() - with general_console.capture() as capture: - styled_err = Text(err_str, style=Cmd2Style.ERROR if ex.apply_style else "") - general_console.print(styled_err, end="") - print_formatted_text(ANSI(capture.get())) - return - except Exception as ex: # noqa: BLE001 - formatted_exception = self.cmd_app.format_exception(ex) - print_formatted_text(ANSI(formatted_exception)) + completions = self.cmd_app.complete( + text, line=line, begidx=begidx, endidx=endidx, custom_settings=self.custom_settings + ) + + if completions.completion_error: + print_formatted_text(ANSI(completions.completion_error + "\n")) return # Print completion table if present From 96477e27d050858e62f5ca76385be4601050373d Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sun, 15 Feb 2026 01:13:51 -0500 Subject: [PATCH 13/30] Fixed most tests in test_argparse_completer.py. --- tests/test_argparse_completer.py | 385 +++++++++++++------------------ 1 file changed, 162 insertions(+), 223 deletions(-) diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index dbc79b0a1..6f3f332a0 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -1,7 +1,6 @@ """Unit/functional testing for argparse completer in cmd2""" import argparse -import numbers from typing import cast import pytest @@ -10,9 +9,11 @@ import cmd2 import cmd2.string_utils as su from cmd2 import ( + Choices, Cmd2ArgumentParser, CompletionError, CompletionItem, + Completions, argparse_completer, argparse_custom, with_argparser, @@ -20,7 +21,6 @@ from cmd2 import rich_utils as ru from .conftest import ( - complete_tester, normalize, run_cmd, with_ansi_style, @@ -31,11 +31,11 @@ standalone_completions = ['standalone', 'completer'] -def standalone_choice_provider(cli: cmd2.Cmd) -> list[str]: - return standalone_choices +def standalone_choice_provider(cli: cmd2.Cmd) -> Choices: + return Choices.from_values(standalone_choices) -def standalone_completer(cli: cmd2.Cmd, text: str, line: str, begidx: int, endidx: int) -> list[str]: +def standalone_completer(cli: cmd2.Cmd, text: str, line: str, begidx: int, endidx: int) -> Completions: return cli.basic_complete(text, line, begidx, endidx, standalone_completions) @@ -105,7 +105,7 @@ def do_pos_and_flag(self, args: argparse.Namespace) -> None: ############################################################################################################ STR_METAVAR = "HEADLESS" TUPLE_METAVAR = ('arg1', 'others') - CUSTOM_HEADER = ("Custom Header",) + CUSTOM_TABLE_HEADER = ("Custom Header",) # tuples (for sake of immutability) used in our tests (there is a mix of sorted and unsorted on purpose) non_negative_num_choices = (1, 2, 3, 0.5, 22) @@ -113,29 +113,29 @@ def do_pos_and_flag(self, args: argparse.Namespace) -> None: static_choices_list = ('static', 'choices', 'stop', 'here') choices_from_provider = ('choices', 'provider', 'probably', 'improved') completion_item_choices = ( - CompletionItem('choice_1', ['Description 1']), + CompletionItem('choice_1', table_row=['Description 1']), # Make this the longest description so we can test display width. - CompletionItem('choice_2', [su.stylize("String with style", style=cmd2.Color.BLUE)]), - CompletionItem('choice_3', [Text("Text with style", style=cmd2.Color.RED)]), + CompletionItem('choice_2', table_row=[su.stylize("String with style", style=cmd2.Color.BLUE)]), + CompletionItem('choice_3', table_row=[Text("Text with style", style=cmd2.Color.RED)]), ) # This tests that CompletionItems created with numerical values are sorted as numbers. num_completion_items = ( - CompletionItem(5, ["Five"]), - CompletionItem(1.5, ["One.Five"]), - CompletionItem(2, ["Five"]), + CompletionItem(5, table_row=["Five"]), + CompletionItem(1.5, table_row=["One.Five"]), + CompletionItem(2, table_row=["Five"]), ) - def choices_provider(self) -> tuple[str]: + def choices_provider(self) -> Choices: """Method that provides choices""" - return self.choices_from_provider + return Choices.from_values(self.choices_from_provider) def completion_item_method(self) -> list[CompletionItem]: """Choices method that returns CompletionItems""" items = [] for i in range(10): main_str = f'main_str{i}' - items.append(CompletionItem(main_str, ['blah blah'])) + items.append(CompletionItem(main_str, table_row=['blah blah'])) return items choices_parser = Cmd2ArgumentParser() @@ -149,7 +149,7 @@ def completion_item_method(self) -> list[CompletionItem]: "--desc_header", help='this arg has a descriptive header', choices_provider=completion_item_method, - table_header=CUSTOM_HEADER, + table_header=CUSTOM_TABLE_HEADER, ) choices_parser.add_argument( "--no_header", @@ -192,13 +192,13 @@ def do_choices(self, args: argparse.Namespace) -> None: completions_for_pos_1 = ('completions', 'positional_1', 'probably', 'missed', 'spot') completions_for_pos_2 = ('completions', 'positional_2', 'probably', 'missed', 'me') - def flag_completer(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: + def flag_completer(self, text: str, line: str, begidx: int, endidx: int) -> Completions: return self.basic_complete(text, line, begidx, endidx, self.completions_for_flag) - def pos_1_completer(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: + def pos_1_completer(self, text: str, line: str, begidx: int, endidx: int) -> Completions: return self.basic_complete(text, line, begidx, endidx, self.completions_for_pos_1) - def pos_2_completer(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: + def pos_2_completer(self, text: str, line: str, begidx: int, endidx: int) -> Completions: return self.basic_complete(text, line, begidx, endidx, self.completions_for_pos_2) completer_parser = Cmd2ArgumentParser() @@ -285,13 +285,13 @@ def do_raise_completion_error(self, args: argparse.Namespace) -> None: ############################################################################################################ # Begin code related to receiving arg_tokens ############################################################################################################ - def choices_takes_arg_tokens(self, arg_tokens: dict[str, list[str]]) -> list[str]: + def choices_takes_arg_tokens(self, arg_tokens: dict[str, list[str]]) -> Choices: """Choices function that receives arg_tokens from ArgparseCompleter""" - return [arg_tokens['parent_arg'][0], arg_tokens['subcommand'][0]] + return Choices.from_values([arg_tokens['parent_arg'][0], arg_tokens['subcommand'][0]]) def completer_takes_arg_tokens( self, text: str, line: str, begidx: int, endidx: int, arg_tokens: dict[str, list[str]] - ) -> list[str]: + ) -> Completions: """Completer function that receives arg_tokens from ArgparseCompleter""" match_against = [arg_tokens['parent_arg'][0], arg_tokens['subcommand'][0]] return self.basic_complete(text, line, begidx, endidx, match_against) @@ -342,7 +342,7 @@ def do_standalone(self, args: argparse.Namespace) -> None: @pytest.fixture -def ac_app(): +def ac_app() -> ArgparseCompleterTester: return ArgparseCompleterTester() @@ -362,10 +362,10 @@ def test_bad_subcommand_help(ac_app) -> None: @pytest.mark.parametrize( - ('command', 'text', 'completions'), + ('command', 'text', 'expected'), [ - ('', 'mus', ['music ']), - ('music', 'cre', ['create ']), + ('', 'mus', ['music']), + ('music', 'cre', ['create']), ('music', 'creab', []), ('music create', '', ['jazz', 'rock']), ('music crea', 'jazz', []), @@ -374,40 +374,35 @@ def test_bad_subcommand_help(ac_app) -> None: ('music fake', '', []), ], ) -def test_complete_help(ac_app, command, text, completions) -> None: +def test_complete_help(ac_app, command, text, expected) -> None: line = f'help {command} {text}' endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, ac_app) - if completions: - assert first_match is not None - else: - assert first_match is None - - assert ac_app.completion_matches == sorted(completions, key=ac_app.default_sort_key) + completions = ac_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() @pytest.mark.parametrize( - ('subcommand', 'text', 'completions'), - [('create', '', ['jazz', 'rock']), ('create', 'ja', ['jazz ']), ('create', 'foo', []), ('creab', 'ja', [])], + ('subcommand', 'text', 'expected'), + [ + ('create', '', ['jazz', 'rock']), + ('create', 'ja', ['jazz']), + ('create', 'foo', []), + ('creab', 'ja', []), + ], ) -def test_subcommand_completions(ac_app, subcommand, text, completions) -> None: +def test_subcommand_completions(ac_app, subcommand, text, expected) -> None: line = f'music {subcommand} {text}' endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, ac_app) - if completions: - assert first_match is not None - else: - assert first_match is None - - assert ac_app.completion_matches == sorted(completions, key=ac_app.default_sort_key) + completions = ac_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() @pytest.mark.parametrize( - ('command_and_args', 'text', 'completion_matches', 'display_matches'), + ('command_and_args', 'text', 'expected_matches', 'expected_displays'), [ # Complete all flags (suppressed will not show) ( @@ -455,13 +450,13 @@ def test_subcommand_completions(ac_app, subcommand, text, completions) -> None: ], ), # Complete individual flag - ('flag', '-n', ['-n '], ['[-n]']), - ('flag', '--n', ['--normal_flag '], ['[--normal_flag]']), + ('flag', '-n', ['-n'], ['[-n]']), + ('flag', '--n', ['--normal_flag'], ['[--normal_flag]']), # No flags should complete until current flag has its args ('flag --append_flag', '-', [], []), # Complete REMAINDER flag name - ('flag', '-r', ['-r '], ['[-r]']), - ('flag', '--rem', ['--remainder_flag '], ['[--remainder_flag]']), + ('flag', '-r', ['-r'], ['[-r]']), + ('flag', '--rem', ['--remainder_flag'], ['[--remainder_flag]']), # No flags after a REMAINDER should complete ('flag -r value', '-', [], []), ('flag --remainder_flag value', '--', [], []), @@ -526,11 +521,11 @@ def test_subcommand_completions(ac_app, subcommand, text, completions) -> None: # Test remaining flag names complete after all positionals are complete ('pos_and_flag', '', ['a', 'choice'], ['a', 'choice']), ('pos_and_flag choice ', '', ['-f', '-h'], ['[-f, --flag]', '[-h, --help]']), - ('pos_and_flag choice -f ', '', ['-h '], ['[-h, --help]']), + ('pos_and_flag choice -f ', '', ['-h'], ['[-h, --help]']), ('pos_and_flag choice -f -h ', '', [], []), ], ) -def test_autcomp_flag_completion(ac_app, command_and_args, text, completion_matches, display_matches) -> None: +def test_autcomp_flag_completion(ac_app, command_and_args, text, expected_matches, expected_displays) -> None: line = f'{command_and_args} {text}' endidx = len(line) begidx = endidx - len(text) @@ -546,41 +541,30 @@ def test_autcomp_flag_completion(ac_app, command_and_args, text, completion_matc @pytest.mark.parametrize( - ('flag', 'text', 'completions'), + ('flag', 'text', 'expected'), [ ('-l', '', ArgparseCompleterTester.static_choices_list), ('--list', 's', ['static', 'stop']), ('-p', '', ArgparseCompleterTester.choices_from_provider), ('--provider', 'pr', ['provider', 'probably']), ('-n', '', ArgparseCompleterTester.num_choices), - ('--num', '1', ['1 ']), + ('--num', '1', ['1']), ('--num', '-', [-1, -2, -12]), ('--num', '-1', [-1, -12]), ('--num_completion_items', '', ArgparseCompleterTester.num_completion_items), ], ) -def test_autocomp_flag_choices_completion(ac_app, flag, text, completions) -> None: +def test_autocomp_flag_choices_completion(ac_app, flag, text, expected) -> None: line = f'choices {flag} {text}' endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, ac_app) - if completions: - assert first_match is not None - else: - assert first_match is None - - # Numbers will be sorted in ascending order and then converted to strings by ArgparseCompleter - if completions and all(isinstance(x, numbers.Number) for x in completions): - completions = [str(x) for x in sorted(completions)] - else: - completions = sorted(completions, key=ac_app.default_sort_key) - - assert ac_app.completion_matches == completions + completions = ac_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() @pytest.mark.parametrize( - ('pos', 'text', 'completions'), + ('pos', 'text', 'expected'), [ (1, '', ArgparseCompleterTester.static_choices_list), (1, 's', ['static', 'stop']), @@ -591,25 +575,14 @@ def test_autocomp_flag_choices_completion(ac_app, flag, text, completions) -> No (4, '', []), ], ) -def test_autocomp_positional_choices_completion(ac_app, pos, text, completions) -> None: +def test_autocomp_positional_choices_completion(ac_app, pos, text, expected) -> None: # Generate line were preceding positionals are already filled line = 'choices {} {}'.format('foo ' * (pos - 1), text) endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, ac_app) - if completions: - assert first_match is not None - else: - assert first_match is None - - # Numbers will be sorted in ascending order and then converted to strings by ArgparseCompleter - if completions and all(isinstance(x, numbers.Number) for x in completions): - completions = [str(x) for x in sorted(completions)] - else: - completions = sorted(completions, key=ac_app.default_sort_key) - - assert ac_app.completion_matches == completions + completions = ac_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_flag_sorting(ac_app) -> None: @@ -633,25 +606,23 @@ def test_flag_sorting(ac_app) -> None: @pytest.mark.parametrize( - ('flag', 'text', 'completions'), - [('-c', '', ArgparseCompleterTester.completions_for_flag), ('--completer', 'f', ['flag', 'fairly'])], + ('flag', 'text', 'expected'), + [ + ('-c', '', ArgparseCompleterTester.completions_for_flag), + ('--completer', 'f', ['flag', 'fairly']), + ], ) -def test_autocomp_flag_completers(ac_app, flag, text, completions) -> None: +def test_autocomp_flag_completers(ac_app, flag, text, expected) -> None: line = f'completer {flag} {text}' endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, ac_app) - if completions: - assert first_match is not None - else: - assert first_match is None - - assert ac_app.completion_matches == sorted(completions, key=ac_app.default_sort_key) + completions = ac_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() @pytest.mark.parametrize( - ('pos', 'text', 'completions'), + ('pos', 'text', 'expected'), [ (1, '', ArgparseCompleterTester.completions_for_pos_1), (1, 'p', ['positional_1', 'probably']), @@ -659,19 +630,14 @@ def test_autocomp_flag_completers(ac_app, flag, text, completions) -> None: (2, 'm', ['missed', 'me']), ], ) -def test_autocomp_positional_completers(ac_app, pos, text, completions) -> None: +def test_autocomp_positional_completers(ac_app, pos, text, expected) -> None: # Generate line were preceding positionals are already filled line = 'completer {} {}'.format('foo ' * (pos - 1), text) endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, ac_app) - if completions: - assert first_match is not None - else: - assert first_match is None - - assert ac_app.completion_matches == sorted(completions, key=ac_app.default_sort_key) + completions = ac_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_autocomp_blank_token(ac_app) -> None: @@ -691,7 +657,8 @@ def test_autocomp_blank_token(ac_app) -> None: completer = ArgparseCompleter(ac_app.completer_parser, ac_app) tokens = ['-c', blank, text] completions = completer.complete(text, line, begidx, endidx, tokens) - assert sorted(completions) == sorted(ArgparseCompleterTester.completions_for_pos_1) + expected = ArgparseCompleterTester.completions_for_pos_1 + assert completions.to_strings() == Completions.from_values(expected).to_strings() # Blank arg for first positional will be consumed. Therefore we expect to be completing the second positional. text = '' @@ -702,25 +669,23 @@ def test_autocomp_blank_token(ac_app) -> None: completer = ArgparseCompleter(ac_app.completer_parser, ac_app) tokens = [blank, text] completions = completer.complete(text, line, begidx, endidx, tokens) - assert sorted(completions) == sorted(ArgparseCompleterTester.completions_for_pos_2) + expected = ArgparseCompleterTester.completions_for_pos_2 + assert completions.to_strings() == Completions.from_values(expected).to_strings() @with_ansi_style(ru.AllowStyle.ALWAYS) -def test_completion_items(ac_app) -> None: - # First test CompletionItems created from strings +def test_completion_tables(ac_app) -> None: + # First test completion table created from strings text = '' line = f'choices --completion_items {text}' endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, ac_app) - assert first_match is not None - assert len(ac_app.completion_matches) == len(ac_app.completion_item_choices) - assert len(ac_app.display_matches) == len(ac_app.completion_item_choices) + completions = ac_app.complete(text, line, begidx, endidx) + assert len(completions) == len(ac_app.completion_item_choices) + lines = completions.completion_table.splitlines() - lines = ac_app.formatted_completions.splitlines() - - # Since the CompletionItems were created from strings, the left-most column is left-aligned. + # Since the completion table was created from strings, the left-most column is left-aligned. # Therefore choice_1 will begin the line (with 1 space for padding). assert lines[2].startswith(' choice_1') assert lines[2].strip().endswith('Description 1') @@ -733,27 +698,24 @@ def test_completion_items(ac_app) -> None: # Verify that the styled Rich Text also rendered. assert lines[4].endswith("\x1b[31mText with style \x1b[0m ") - # Now test CompletionItems created from numbers + # Now test completion table created from numbers text = '' line = f'choices --num_completion_items {text}' endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, ac_app) - assert first_match is not None - assert len(ac_app.completion_matches) == len(ac_app.num_completion_items) - assert len(ac_app.display_matches) == len(ac_app.num_completion_items) - - lines = ac_app.formatted_completions.splitlines() + completions = ac_app.complete(text, line, begidx, endidx) + assert len(completions) == len(ac_app.num_completion_items) + lines = completions.completion_table.splitlines() - # Since the CompletionItems were created from numbers, the left-most column is right-aligned. + # Since the completion table was created from numbers, the left-most column is right-aligned. # Therefore 1.5 will be right-aligned. assert lines[2].startswith(" 1.5") assert lines[2].strip().endswith('One.Five') @pytest.mark.parametrize( - ('num_aliases', 'show_description'), + ('num_aliases', 'show_table'), [ # The number of completion results determines if a completion table is displayed. # The count must be greater than 1 and less than ac_app.max_completion_table_items, @@ -763,7 +725,7 @@ def test_completion_items(ac_app) -> None: (100, False), ], ) -def test_max_completion_table_items(ac_app, num_aliases, show_description) -> None: +def test_max_completion_table_items(ac_app, num_aliases, show_table) -> None: # Create aliases for i in range(num_aliases): run_cmd(ac_app, f'alias create fake_alias{i} help') @@ -775,25 +737,13 @@ def test_max_completion_table_items(ac_app, num_aliases, show_description) -> No endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, ac_app) - assert first_match is not None - assert len(ac_app.completion_matches) == num_aliases - assert len(ac_app.display_matches) == num_aliases - - assert bool(ac_app.formatted_completions) == show_description - if show_description: - # If show_description is True, the table will show both the alias name and value - description_displayed = False - for line in ac_app.formatted_completions.splitlines(): - if 'fake_alias0' in line and 'help' in line: - description_displayed = True - break - - assert description_displayed + completions = ac_app.complete(text, line, begidx, endidx) + assert len(completions) == num_aliases + assert bool(completions.completion_table) == show_table @pytest.mark.parametrize( - ('args', 'completions'), + ('args', 'expected'), [ # Flag with nargs = 2 ('--set_value', ArgparseCompleterTester.set_value_choices), @@ -816,9 +766,9 @@ def test_max_completion_table_items(ac_app, num_aliases, show_description) -> No ('--range some range', ArgparseCompleterTester.positional_choices), # Flag with nargs = REMAINDER ('--remainder', ArgparseCompleterTester.remainder_choices), - ('--remainder remainder ', ['choices ']), + ('--remainder remainder ', ['choices']), # No more flags can appear after a REMAINDER flag) - ('--remainder choices --set_value', ['remainder ']), + ('--remainder choices --set_value', ['remainder']), # Double dash ends the current flag ('--range choice --', ArgparseCompleterTester.positional_choices), # Double dash ends a REMAINDER flag @@ -836,26 +786,21 @@ def test_max_completion_table_items(ac_app, num_aliases, show_description) -> No ('positional --range choice --', ['the', 'choices']), # REMAINDER positional ('the positional', ArgparseCompleterTester.remainder_choices), - ('the positional remainder', ['choices ']), + ('the positional remainder', ['choices']), ('the positional remainder choices', []), # REMAINDER positional. Flags don't work in REMAINDER ('the positional --set_value', ArgparseCompleterTester.remainder_choices), - ('the positional remainder --set_value', ['choices ']), + ('the positional remainder --set_value', ['choices']), ], ) -def test_autcomp_nargs(ac_app, args, completions) -> None: +def test_autcomp_nargs(ac_app, args, expected) -> None: text = '' line = f'nargs {args} {text}' endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, ac_app) - if completions: - assert first_match is not None - else: - assert first_match is None - - assert ac_app.completion_matches == sorted(completions, key=ac_app.default_sort_key) + completions = ac_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() @pytest.mark.parametrize( @@ -891,26 +836,24 @@ def test_autcomp_nargs(ac_app, args, completions) -> None: ('nargs --range', '--', True), ], ) -def test_unfinished_flag_error(ac_app, command_and_args, text, is_error, capsys) -> None: +def test_unfinished_flag_error(ac_app, command_and_args, text, is_error) -> None: line = f'{command_and_args} {text}' endidx = len(line) begidx = endidx - len(text) - complete_tester(text, line, begidx, endidx, ac_app) + completions = ac_app.complete(text, line, begidx, endidx) + assert is_error == all(x in completions.completion_error for x in ["Error: argument", "expected"]) - out, _err = capsys.readouterr() - assert is_error == all(x in out for x in ["Error: argument", "expected"]) - -def test_completion_items_arg_header(ac_app) -> None: +def test_completion_table_arg_header(ac_app) -> None: # Test when metavar is None text = '' line = f'choices --desc_header {text}' endidx = len(line) begidx = endidx - len(text) - complete_tester(text, line, begidx, endidx, ac_app) - assert "DESC_HEADER" in normalize(ac_app.formatted_completions)[0] + completions = ac_app.complete(text, line, begidx, endidx) + assert "DESC_HEADER" in normalize(completions.completion_table)[0] # Test when metavar is a string text = '' @@ -918,8 +861,8 @@ def test_completion_items_arg_header(ac_app) -> None: endidx = len(line) begidx = endidx - len(text) - complete_tester(text, line, begidx, endidx, ac_app) - assert ac_app.STR_METAVAR in normalize(ac_app.formatted_completions)[0] + completions = ac_app.complete(text, line, begidx, endidx) + assert ac_app.STR_METAVAR in normalize(completions.completion_table)[0] # Test when metavar is a tuple text = '' @@ -928,8 +871,8 @@ def test_completion_items_arg_header(ac_app) -> None: begidx = endidx - len(text) # We are completing the first argument of this flag. The first element in the tuple should be the column header. - complete_tester(text, line, begidx, endidx, ac_app) - assert ac_app.TUPLE_METAVAR[0].upper() in normalize(ac_app.formatted_completions)[0] + completions = ac_app.complete(text, line, begidx, endidx) + assert ac_app.TUPLE_METAVAR[0].upper() in normalize(completions.completion_table)[0] text = '' line = f'choices --tuple_metavar token_1 {text}' @@ -937,8 +880,8 @@ def test_completion_items_arg_header(ac_app) -> None: begidx = endidx - len(text) # We are completing the second argument of this flag. The second element in the tuple should be the column header. - complete_tester(text, line, begidx, endidx, ac_app) - assert ac_app.TUPLE_METAVAR[1].upper() in normalize(ac_app.formatted_completions)[0] + completions = ac_app.complete(text, line, begidx, endidx) + assert ac_app.TUPLE_METAVAR[1].upper() in normalize(completions.completion_table)[0] text = '' line = f'choices --tuple_metavar token_1 token_2 {text}' @@ -947,11 +890,11 @@ def test_completion_items_arg_header(ac_app) -> None: # We are completing the third argument of this flag. It should still be the second tuple element # in the column header since the tuple only has two strings in it. - complete_tester(text, line, begidx, endidx, ac_app) - assert ac_app.TUPLE_METAVAR[1].upper() in normalize(ac_app.formatted_completions)[0] + completions = ac_app.complete(text, line, begidx, endidx) + assert ac_app.TUPLE_METAVAR[1].upper() in normalize(completions.completion_table)[0] -def test_completion_items_table_header(ac_app) -> None: +def test_completion_table_header(ac_app) -> None: from cmd2.argparse_completer import ( DEFAULT_TABLE_HEADER, ) @@ -962,8 +905,8 @@ def test_completion_items_table_header(ac_app) -> None: endidx = len(line) begidx = endidx - len(text) - complete_tester(text, line, begidx, endidx, ac_app) - assert ac_app.CUSTOM_TABLE_HEADER[0] in normalize(ac_app.formatted_completions)[0] + completions = ac_app.complete(text, line, begidx, endidx) + assert ac_app.CUSTOM_TABLE_HEADER[0] in normalize(completions.completion_table)[0] # This argument did not provide a table header, so it should be DEFAULT_TABLE_HEADER text = '' @@ -971,8 +914,8 @@ def test_completion_items_table_header(ac_app) -> None: endidx = len(line) begidx = endidx - len(text) - complete_tester(text, line, begidx, endidx, ac_app) - assert DEFAULT_TABLE_HEADER[0] in normalize(ac_app.formatted_completions)[0] + completions = ac_app.complete(text, line, begidx, endidx) + assert DEFAULT_TABLE_HEADER[0] in normalize(completions.completion_table)[0] @pytest.mark.parametrize( @@ -1001,30 +944,28 @@ def test_completion_items_table_header(ac_app) -> None: ('nargs the choices remainder', '-', True), ], ) -def test_autocomp_hint(ac_app, command_and_args, text, has_hint, capsys) -> None: +def test_autocomp_no_results_hint(ac_app, command_and_args, text, has_hint) -> None: + """Test whether _NoResultsErrors include hint text.""" line = f'{command_and_args} {text}' endidx = len(line) begidx = endidx - len(text) - complete_tester(text, line, begidx, endidx, ac_app) - out, _err = capsys.readouterr() + completions = ac_app.complete(text, line, begidx, endidx) if has_hint: - assert "Hint:\n" in out + assert "Hint:\n" in completions.completion_error else: - assert not out + assert not completions.completion_error -def test_autocomp_hint_no_help_text(ac_app, capsys) -> None: +def test_autocomp_hint_no_help_text(ac_app) -> None: + """Tests that a hint for an arg with no help text only includes the arg's name.""" text = '' line = f'hint foo {text}' endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, ac_app) - out, _err = capsys.readouterr() - - assert first_match is None - assert out != '''\nHint:\n NO_HELP_POS\n\n''' + completions = ac_app.complete(text, line, begidx, endidx) + assert completions.completion_error.strip() == "Hint:\n no_help_pos" @pytest.mark.parametrize( @@ -1036,20 +977,17 @@ def test_autocomp_hint_no_help_text(ac_app, capsys) -> None: ('', 'completer'), ], ) -def test_completion_error(ac_app, capsys, args, text) -> None: +def test_completion_error(ac_app, args, text) -> None: line = f'raise_completion_error {args} {text}' endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, ac_app) - out, _err = capsys.readouterr() - - assert first_match is None - assert f"{text} broke something" in out + completions = ac_app.complete(text, line, begidx, endidx) + assert f"{text} broke something" in completions.completion_error @pytest.mark.parametrize( - ('command_and_args', 'completions'), + ('command_and_args', 'expected'), [ # Exercise a choices function that receives arg_tokens dictionary ('arg_tokens choice subcmd', ['choice', 'subcmd']), @@ -1059,19 +997,14 @@ def test_completion_error(ac_app, capsys, args, text) -> None: ('arg_tokens completer subcmd --parent_arg override fake', ['override', 'subcmd']), ], ) -def test_arg_tokens(ac_app, command_and_args, completions) -> None: +def test_arg_tokens(ac_app, command_and_args, expected) -> None: text = '' line = f'{command_and_args} {text}' endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, ac_app) - if completions: - assert first_match is not None - else: - assert first_match is None - - assert ac_app.completion_matches == sorted(completions, key=ac_app.default_sort_key) + completions = ac_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() @pytest.mark.parametrize( @@ -1080,7 +1013,7 @@ def test_arg_tokens(ac_app, command_and_args, completions) -> None: # Group isn't done. The optional positional's hint will show and flags will not complete. ('mutex', '', 'the optional positional', None), # Group isn't done. Flag name will still complete. - ('mutex', '--fl', '', '--flag '), + ('mutex', '--fl', '', '--flag'), # Group isn't done. Flag hint will show. ('mutex --flag', '', 'the flag arg', None), # Group finished by optional positional. No flag name will complete. @@ -1097,15 +1030,18 @@ def test_arg_tokens(ac_app, command_and_args, completions) -> None: ('mutex --flag flag_val --flag', '', 'the flag arg', None), ], ) -def test_complete_mutex_group(ac_app, command_and_args, text, output_contains, first_match, capsys) -> None: +def test_complete_mutex_group(ac_app, command_and_args, text, output_contains, first_match) -> None: line = f'{command_and_args} {text}' endidx = len(line) begidx = endidx - len(text) - assert first_match == complete_tester(text, line, begidx, endidx, ac_app) + completions = ac_app.complete(text, line, begidx, endidx) + if first_match is None: + assert not completions + else: + assert first_match == completions[0].text - out, _err = capsys.readouterr() - assert output_contains in out + assert output_contains in completions.completion_error def test_single_prefix_char() -> None: @@ -1172,17 +1108,20 @@ def test_complete_command_help_no_tokens(ac_app) -> None: @pytest.mark.parametrize( - ('flag', 'completions'), [('--provider', standalone_choices), ('--completer', standalone_completions)] + ('flag', 'expected'), + [ + ('--provider', standalone_choices), + ('--completer', standalone_completions), + ], ) -def test_complete_standalone(ac_app, flag, completions) -> None: +def test_complete_standalone(ac_app, flag, expected) -> None: text = '' line = f'standalone {flag} {text}' endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, ac_app) - assert first_match is not None - assert ac_app.completion_matches == sorted(completions, key=ac_app.default_sort_key) + completions = ac_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() # Custom ArgparseCompleter-based class @@ -1272,13 +1211,13 @@ def test_default_custom_completer_type(custom_completer_app: CustomCompleterApp) # The flag should complete because app is ready custom_completer_app.is_ready = True - assert complete_tester(text, line, begidx, endidx, custom_completer_app) is not None - assert custom_completer_app.completion_matches == ['--myflag '] + completions = custom_completer_app.complete(text, line, begidx, endidx) + assert completions.items[0].text == "--myflag" # The flag should not complete because app is not ready custom_completer_app.is_ready = False - assert complete_tester(text, line, begidx, endidx, custom_completer_app) is None - assert not custom_completer_app.completion_matches + completions = custom_completer_app.complete(text, line, begidx, endidx) + assert not completions finally: # Restore the default completer @@ -1294,13 +1233,13 @@ def test_custom_completer_type(custom_completer_app: CustomCompleterApp) -> None # The flag should complete because app is ready custom_completer_app.is_ready = True - assert complete_tester(text, line, begidx, endidx, custom_completer_app) is not None - assert custom_completer_app.completion_matches == ['--myflag '] + completions = custom_completer_app.complete(text, line, begidx, endidx) + assert completions.items[0].text == "--myflag" # The flag should not complete because app is not ready custom_completer_app.is_ready = False - assert complete_tester(text, line, begidx, endidx, custom_completer_app) is None - assert not custom_completer_app.completion_matches + completions = custom_completer_app.complete(text, line, begidx, endidx) + assert not completions def test_decorated_subcmd_custom_completer(custom_completer_app: CustomCompleterApp) -> None: @@ -1313,12 +1252,12 @@ def test_decorated_subcmd_custom_completer(custom_completer_app: CustomCompleter # The flag should complete regardless of ready state since this subcommand isn't using the custom completer custom_completer_app.is_ready = True - assert complete_tester(text, line, begidx, endidx, custom_completer_app) is not None - assert custom_completer_app.completion_matches == ['--myflag '] + completions = custom_completer_app.complete(text, line, begidx, endidx) + assert completions.items[0].text == "--myflag" custom_completer_app.is_ready = False - assert complete_tester(text, line, begidx, endidx, custom_completer_app) is not None - assert custom_completer_app.completion_matches == ['--myflag '] + completions = custom_completer_app.complete(text, line, begidx, endidx) + assert completions.items[0].text == "--myflag" # Now test the subcommand with the custom completer text = '--m' @@ -1328,13 +1267,13 @@ def test_decorated_subcmd_custom_completer(custom_completer_app: CustomCompleter # The flag should complete because app is ready custom_completer_app.is_ready = True - assert complete_tester(text, line, begidx, endidx, custom_completer_app) is not None - assert custom_completer_app.completion_matches == ['--myflag '] + completions = custom_completer_app.complete(text, line, begidx, endidx) + assert completions.items[0].text == "--myflag" # The flag should not complete because app is not ready custom_completer_app.is_ready = False - assert complete_tester(text, line, begidx, endidx, custom_completer_app) is None - assert not custom_completer_app.completion_matches + completions = custom_completer_app.complete(text, line, begidx, endidx) + assert not completions def test_add_parser_custom_completer() -> None: From f2cb61d7f90a1105910de418a0528b3343ec5e70 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 16 Feb 2026 01:49:20 -0500 Subject: [PATCH 14/30] Fixed remaining tests in test_argparse_completer.py. --- tests/test_argparse_completer.py | 238 ++++++++++++++++--------------- 1 file changed, 122 insertions(+), 116 deletions(-) diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 6f3f332a0..a3e2f3df0 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -58,7 +58,7 @@ def __init__(self, *args, **kwargs) -> None: # Add subcommands to music -> create music_create_subparsers = music_create_parser.add_subparsers() music_create_jazz_parser = music_create_subparsers.add_parser('jazz', help='create jazz') - music_create_rock_parser = music_create_subparsers.add_parser('rock', help='create rocks') + music_create_rock_parser = music_create_subparsers.add_parser('rock', help='create rock') @with_argparser(music_parser) def do_music(self, args: argparse.Namespace) -> None: @@ -74,6 +74,7 @@ def do_music(self, args: argparse.Namespace) -> None: flag_parser.add_argument('-a', '--append_flag', help='append flag', action='append') flag_parser.add_argument('-o', '--append_const_flag', help='append const flag', action='append_const', const=True) flag_parser.add_argument('-c', '--count_flag', help='count flag', action='count') + flag_parser.add_argument('-e', '--extend_flag', help='extend flag', action='extend') flag_parser.add_argument('-s', '--suppressed_flag', help=argparse.SUPPRESS, action='store_true') flag_parser.add_argument('-r', '--remainder_flag', nargs=argparse.REMAINDER, help='a remainder flag') flag_parser.add_argument('-q', '--required_flag', required=True, help='a required flag', action='store_true') @@ -146,14 +147,14 @@ def completion_item_method(self) -> list[CompletionItem]: "-p", "--provider", help="a flag populated with a choices provider", choices_provider=choices_provider ) choices_parser.add_argument( - "--desc_header", - help='this arg has a descriptive header', + "--table_header", + help='this arg has a table header', choices_provider=completion_item_method, table_header=CUSTOM_TABLE_HEADER, ) choices_parser.add_argument( "--no_header", - help='this arg has no descriptive header', + help='this arg has no table header', choices_provider=completion_item_method, metavar=STR_METAVAR, ) @@ -299,7 +300,7 @@ def completer_takes_arg_tokens( arg_tokens_parser = Cmd2ArgumentParser() arg_tokens_parser.add_argument('parent_arg', help='arg from a parent parser') - # Create a subcommand for to exercise receiving parent_tokens and subcommand name in arg_tokens + # Create a subcommand to exercise receiving parent_tokens and subcommand name in arg_tokens arg_tokens_subparser = arg_tokens_parser.add_subparsers(dest='subcommand') arg_tokens_subcmd_parser = arg_tokens_subparser.add_parser('subcmd') @@ -340,6 +341,26 @@ def do_mutex(self, args: argparse.Namespace) -> None: def do_standalone(self, args: argparse.Namespace) -> None: pass + ############################################################################################################ + # Begin code related to display_meta data + ############################################################################################################ + meta_parser = Cmd2ArgumentParser() + + # Add subcommands to meta + meta_subparsers = meta_parser.add_subparsers() + + # Create subcommands with and without help text + meta_helpful_parser = meta_subparsers.add_parser('helpful', help='my helpful text') + meta_helpless_parser = meta_subparsers.add_parser('helpless') + + # Create flags with and without help text + meta_helpful_parser.add_argument('--helpful_flag', help="a helpful flag") + meta_helpless_parser.add_argument('--helpless_flag') + + @with_argparser(meta_parser) + def do_meta(self, args: argparse.Namespace) -> None: + pass + @pytest.fixture def ac_app() -> ArgparseCompleterTester: @@ -402,142 +423,122 @@ def test_subcommand_completions(ac_app, subcommand, text, expected) -> None: @pytest.mark.parametrize( - ('command_and_args', 'text', 'expected_matches', 'expected_displays'), + # expected_data is a list of tuples with completion text and display values + ('command_and_args', 'text', 'expected_data'), [ # Complete all flags (suppressed will not show) ( 'flag', '-', [ - '-a', - '-c', - '-h', - '-n', - '-o', - '-q', - '-r', - ], - [ - '-q, --required_flag', - '[-o, --append_const_flag]', - '[-a, --append_flag]', - '[-c, --count_flag]', - '[-h, --help]', - '[-n, --normal_flag]', - '[-r, --remainder_flag]', + ("-a", "[-a, --append_flag]"), + ("-c", "[-c, --count_flag]"), + ('-e', '[-e, --extend_flag]'), + ("-h", "[-h, --help]"), + ("-n", "[-n, --normal_flag]"), + ("-o", "[-o, --append_const_flag]"), + ("-q", "-q, --required_flag"), + ("-r", "[-r, --remainder_flag]"), ], ), ( 'flag', '--', [ - '--append_const_flag', - '--append_flag', - '--count_flag', - '--help', - '--normal_flag', - '--remainder_flag', - '--required_flag', - ], - [ - '--required_flag', - '[--append_const_flag]', - '[--append_flag]', - '[--count_flag]', - '[--help]', - '[--normal_flag]', - '[--remainder_flag]', + ('--append_const_flag', '[--append_const_flag]'), + ('--append_flag', '[--append_flag]'), + ('--count_flag', '[--count_flag]'), + ('--extend_flag', '[--extend_flag]'), + ('--help', '[--help]'), + ('--normal_flag', '[--normal_flag]'), + ('--remainder_flag', '[--remainder_flag]'), + ('--required_flag', '--required_flag'), ], ), # Complete individual flag - ('flag', '-n', ['-n'], ['[-n]']), - ('flag', '--n', ['--normal_flag'], ['[--normal_flag]']), + ('flag', '-n', [('-n', '[-n]')]), + ('flag', '--n', [('--normal_flag', '[--normal_flag]')]), # No flags should complete until current flag has its args - ('flag --append_flag', '-', [], []), + ('flag --append_flag', '-', []), # Complete REMAINDER flag name - ('flag', '-r', ['-r'], ['[-r]']), - ('flag', '--rem', ['--remainder_flag'], ['[--remainder_flag]']), + ('flag', '-r', [('-r', '[-r]')]), + ('flag', '--rem', [('--remainder_flag', '[--remainder_flag]')]), # No flags after a REMAINDER should complete - ('flag -r value', '-', [], []), - ('flag --remainder_flag value', '--', [], []), + ('flag -r value', '-', []), + ('flag --remainder_flag value', '--', []), # Suppressed flag should not complete - ('flag', '-s', [], []), - ('flag', '--s', [], []), + ('flag', '-s', []), + ('flag', '--s', []), # A used flag should not show in completions ( 'flag -n', '--', - ['--append_const_flag', '--append_flag', '--count_flag', '--help', '--remainder_flag', '--required_flag'], [ - '--required_flag', - '[--append_const_flag]', - '[--append_flag]', - '[--count_flag]', - '[--help]', - '[--remainder_flag]', + ('--append_const_flag', '[--append_const_flag]'), + ('--append_flag', '[--append_flag]'), + ('--count_flag', '[--count_flag]'), + ('--extend_flag', '[--extend_flag]'), + ('--help', '[--help]'), + ('--remainder_flag', '[--remainder_flag]'), + ('--required_flag', '--required_flag'), ], ), - # Flags with actions set to append, append_const, and count will always show even if they've been used + # Flags with actions set to append, append_const, extend, and count will always show even if they've been used ( - 'flag --append_const_flag -c --append_flag value', + 'flag --append_flag value --append_const_flag --count_flag --extend_flag value', '--', [ - '--append_const_flag', - '--append_flag', - '--count_flag', - '--help', - '--normal_flag', - '--remainder_flag', - '--required_flag', - ], - [ - '--required_flag', - '[--append_const_flag]', - '[--append_flag]', - '[--count_flag]', - '[--help]', - '[--normal_flag]', - '[--remainder_flag]', + ('--append_const_flag', '[--append_const_flag]'), + ('--append_flag', '[--append_flag]'), + ('--count_flag', '[--count_flag]'), + ('--extend_flag', '[--extend_flag]'), + ('--help', '[--help]'), + ('--normal_flag', '[--normal_flag]'), + ('--remainder_flag', '[--remainder_flag]'), + ('--required_flag', '--required_flag'), ], ), # Non-default flag prefix character (+) ( 'plus_flag', '+', - ['+h', '+n', '+q'], - ['+q, ++required_flag', '[+h, ++help]', '[+n, ++normal_flag]'], + [ + ('+h', '[+h, ++help]'), + ('+n', '[+n, ++normal_flag]'), + ('+q', '+q, ++required_flag'), + ], ), ( 'plus_flag', '++', - ['++help', '++normal_flag', '++required_flag'], - ['++required_flag', '[++help]', '[++normal_flag]'], + [ + ('++help', '[++help]'), + ('++normal_flag', '[++normal_flag]'), + ('++required_flag', '++required_flag'), + ], ), # Flag completion should not occur after '--' since that tells argparse all remaining arguments are non-flags - ('flag --', '--', [], []), - ('flag --help --', '--', [], []), - ('plus_flag --', '++', [], []), - ('plus_flag ++help --', '++', [], []), + ('flag --', '--', []), + ('flag --help --', '--', []), + ('plus_flag --', '++', []), + ('plus_flag ++help --', '++', []), # Test remaining flag names complete after all positionals are complete - ('pos_and_flag', '', ['a', 'choice'], ['a', 'choice']), - ('pos_and_flag choice ', '', ['-f', '-h'], ['[-f, --flag]', '[-h, --help]']), - ('pos_and_flag choice -f ', '', ['-h'], ['[-h, --help]']), - ('pos_and_flag choice -f -h ', '', [], []), + ('pos_and_flag', '', [('a', 'a'), ('choice', 'choice')]), + ('pos_and_flag choice ', '', [('-f', '[-f, --flag]'), ('-h', '[-h, --help]')]), + ('pos_and_flag choice -f ', '', [('-h', '[-h, --help]')]), + ('pos_and_flag choice -f -h ', '', []), ], ) -def test_autcomp_flag_completion(ac_app, command_and_args, text, expected_matches, expected_displays) -> None: +def test_autcomp_flag_completion(ac_app, command_and_args, text, expected_data) -> None: line = f'{command_and_args} {text}' endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, ac_app) - if completion_matches: - assert first_match is not None - else: - assert first_match is None + expected_completions = Completions(items=[CompletionItem(value=v, display=d) for v, d in expected_data]) + completions = ac_app.complete(text, line, begidx, endidx) - assert ac_app.completion_matches == sorted(completion_matches, key=ac_app.default_sort_key) - assert ac_app.display_matches == sorted(display_matches, key=ac_app.default_sort_key) + assert completions.to_strings() == expected_completions.to_strings() + assert [item.display for item in completions] == [item.display for item in expected_completions] @pytest.mark.parametrize( @@ -576,7 +577,7 @@ def test_autocomp_flag_choices_completion(ac_app, flag, text, expected) -> None: ], ) def test_autocomp_positional_choices_completion(ac_app, pos, text, expected) -> None: - # Generate line were preceding positionals are already filled + # Generate line where preceding positionals are already filled line = 'choices {} {}'.format('foo ' * (pos - 1), text) endidx = len(line) begidx = endidx - len(text) @@ -585,26 +586,6 @@ def test_autocomp_positional_choices_completion(ac_app, pos, text, expected) -> assert completions.to_strings() == Completions.from_values(expected).to_strings() -def test_flag_sorting(ac_app) -> None: - # This test exercises the case where a positional arg has non-negative integers for its choices. - # ArgparseCompleter will sort these numerically before converting them to strings. As a result, - # cmd2.matches_sorted gets set to True. If no completion matches are returned and the entered - # text looks like the beginning of a flag (e.g -), then ArgparseCompleter will try to complete - # flag names next. Before it does this, cmd2.matches_sorted is reset to make sure the flag names - # get sorted correctly. - option_strings = [action.option_strings[0] for action in ac_app.choices_parser._actions if action.option_strings] - option_strings.sort(key=ac_app.default_sort_key) - - text = '-' - line = f'choices arg1 arg2 arg3 {text}' - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, ac_app) - assert first_match is not None - assert ac_app.completion_matches == option_strings - - @pytest.mark.parametrize( ('flag', 'text', 'expected'), [ @@ -848,12 +829,12 @@ def test_unfinished_flag_error(ac_app, command_and_args, text, is_error) -> None def test_completion_table_arg_header(ac_app) -> None: # Test when metavar is None text = '' - line = f'choices --desc_header {text}' + line = f'choices --table_header {text}' endidx = len(line) begidx = endidx - len(text) completions = ac_app.complete(text, line, begidx, endidx) - assert "DESC_HEADER" in normalize(completions.completion_table)[0] + assert "TABLE_HEADER" in normalize(completions.completion_table)[0] # Test when metavar is a string text = '' @@ -901,7 +882,7 @@ def test_completion_table_header(ac_app) -> None: # This argument provided a table header text = '' - line = f'choices --desc_header {text}' + line = f'choices --table_header {text}' endidx = len(line) begidx = endidx - len(text) @@ -1124,6 +1105,31 @@ def test_complete_standalone(ac_app, flag, expected) -> None: assert completions.to_strings() == Completions.from_values(expected).to_strings() +@pytest.mark.parametrize( + ('subcommand', 'flag', 'display_meta'), + [ + ('helpful', '', 'my helpful text'), + ('helpful', '--helpful_flag', "a helpful flag"), + ('helpless', '', ''), + ('helpless', '--helpless_flag', ''), + ], +) +def test_display_meta(ac_app, subcommand, flag, display_meta) -> None: + """Test that subcommands and flags can have display_meta data.""" + if flag: + text = flag + line = line = f'meta {subcommand} {text}' + else: + text = subcommand + line = line = f'meta {text}' + + endidx = len(line) + begidx = endidx - len(text) + + completions = ac_app.complete(text, line, begidx, endidx) + assert completions[0].display_meta == display_meta + + # Custom ArgparseCompleter-based class class CustomCompleter(argparse_completer.ArgparseCompleter): def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, matched_flags: list[str]) -> list[str]: From 6a59fb8685d91f4377d5831b453d41be9bff8b2b Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 16 Feb 2026 02:11:44 -0500 Subject: [PATCH 15/30] Fixed tests in test_commandset.py. --- tests/test_commandset.py | 79 +++++++++++++++++----------------------- 1 file changed, 33 insertions(+), 46 deletions(-) diff --git a/tests/test_commandset.py b/tests/test_commandset.py index 63df00080..c27493786 100644 --- a/tests/test_commandset.py +++ b/tests/test_commandset.py @@ -7,6 +7,7 @@ import cmd2 from cmd2 import ( + Completions, Settable, ) from cmd2.exceptions import ( @@ -15,7 +16,6 @@ from .conftest import ( WithCommandSets, - complete_tester, normalize, run_cmd, ) @@ -497,8 +497,8 @@ def __init__(self, dummy) -> None: def do_arugula(self, _: cmd2.Statement) -> None: self._cmd.poutput('Arugula') - def complete_style_arg(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: - return ['quartered', 'diced'] + def complete_style_arg(self, text: str, line: str, begidx: int, endidx: int) -> Completions: + return Completions.from_values(['quartered', 'diced']) bokchoy_parser = cmd2.Cmd2ArgumentParser() bokchoy_parser.add_argument('style', completer=complete_style_arg) @@ -549,11 +549,10 @@ def test_subcommands(manual_command_sets_app) -> None: line = f'cut {text}' endidx = len(line) begidx = endidx - first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app) + completions = manual_command_sets_app.complete(text, line, begidx, endidx) - assert first_match is not None # check that the alias shows up correctly - assert manual_command_sets_app.completion_matches == ['banana', 'bananer', 'bokchoy'] + assert completions.to_strings() == Completions.from_values(['banana', 'bananer', 'bokchoy']).to_strings() cmd_result = manual_command_sets_app.app_cmd('cut banana discs') assert 'cutting banana: discs' in cmd_result.stdout @@ -562,11 +561,10 @@ def test_subcommands(manual_command_sets_app) -> None: line = f'cut bokchoy {text}' endidx = len(line) begidx = endidx - first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app) + completions = manual_command_sets_app.complete(text, line, begidx, endidx) - assert first_match is not None # verify that argparse completer in commandset functions correctly - assert manual_command_sets_app.completion_matches == ['diced', 'quartered'] + assert completions.to_strings() == Completions.from_values(['diced', 'quartered']).to_strings() # verify that command set uninstalls without problems manual_command_sets_app.unregister_command_set(fruit_cmds) @@ -594,21 +592,19 @@ def test_subcommands(manual_command_sets_app) -> None: line = f'cut {text}' endidx = len(line) begidx = endidx - first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app) + completions = manual_command_sets_app.complete(text, line, begidx, endidx) - assert first_match is not None # check that the alias shows up correctly - assert manual_command_sets_app.completion_matches == ['banana', 'bananer', 'bokchoy'] + assert completions.to_strings() == Completions.from_values(['banana', 'bananer', 'bokchoy']).to_strings() text = '' line = f'cut bokchoy {text}' endidx = len(line) begidx = endidx - first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app) + completions = manual_command_sets_app.complete(text, line, begidx, endidx) - assert first_match is not None # verify that argparse completer in commandset functions correctly - assert manual_command_sets_app.completion_matches == ['diced', 'quartered'] + assert completions.to_strings() == Completions.from_values(['diced', 'quartered']).to_strings() # disable again and verify can still uninstnall manual_command_sets_app.disable_command('cut', 'disabled for test') @@ -735,8 +731,8 @@ def cut_banana(self, ns: argparse.Namespace) -> None: """Cut banana""" self.poutput('cutting banana: ' + ns.direction) - def complete_style_arg(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: - return ['quartered', 'diced'] + def complete_style_arg(self, text: str, line: str, begidx: int, endidx: int) -> Completions: + return Completions.from_values(['quartered', 'diced']) bokchoy_parser = cmd2.Cmd2ArgumentParser() bokchoy_parser.add_argument('style', completer=complete_style_arg) @@ -759,21 +755,19 @@ def test_static_subcommands(static_subcommands_app) -> None: line = f'cut {text}' endidx = len(line) begidx = endidx - first_match = complete_tester(text, line, begidx, endidx, static_subcommands_app) + completions = static_subcommands_app.complete(text, line, begidx, endidx) - assert first_match is not None # check that the alias shows up correctly - assert static_subcommands_app.completion_matches == ['banana', 'bananer', 'bokchoy'] + assert completions.to_strings() == Completions.from_values(['banana', 'bananer', 'bokchoy']).to_strings() text = '' line = f'cut bokchoy {text}' endidx = len(line) begidx = endidx - first_match = complete_tester(text, line, begidx, endidx, static_subcommands_app) + completions = static_subcommands_app.complete(text, line, begidx, endidx) - assert first_match is not None # verify that argparse completer in commandset functions correctly - assert static_subcommands_app.completion_matches == ['diced', 'quartered'] + assert completions.to_strings() == Completions.from_values(['diced', 'quartered']).to_strings() complete_states_expected_self = None @@ -789,7 +783,7 @@ def __init__(self, dummy) -> None: """Dummy variable prevents this from being autoloaded in other tests""" super().__init__() - def complete_states(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: + def complete_states(self, text: str, line: str, begidx: int, endidx: int) -> Completions: assert self is complete_states_expected_self return self._cmd.basic_complete(text, line, begidx, endidx, self.states) @@ -831,7 +825,7 @@ def do_user_unrelated(self, ns: argparse.Namespace) -> None: self._cmd.poutput(f'something {ns.state}') -def test_cross_commandset_completer(manual_command_sets_app, capsys) -> None: +def test_cross_commandset_completer(manual_command_sets_app) -> None: global complete_states_expected_self # noqa: PLW0603 # This tests the different ways to locate the matching CommandSet when completing an argparse argument. # Exercises the 3 cases in cmd2.Cmd._resolve_func_self() which is called during argparse tab completion. @@ -858,11 +852,10 @@ def test_cross_commandset_completer(manual_command_sets_app, capsys) -> None: endidx = len(line) begidx = endidx complete_states_expected_self = user_sub1 - first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app) + completions = manual_command_sets_app.complete(text, line, begidx, endidx) complete_states_expected_self = None - assert first_match == 'alabama' - assert manual_command_sets_app.completion_matches == list(SupportFuncProvider.states) + assert completions.to_strings() == Completions.from_values(SupportFuncProvider.states).to_strings() assert ( getattr(manual_command_sets_app.cmd_func('user_sub1').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY) @@ -885,11 +878,10 @@ def test_cross_commandset_completer(manual_command_sets_app, capsys) -> None: endidx = len(line) begidx = endidx complete_states_expected_self = func_provider - first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app) + completions = manual_command_sets_app.complete(text, line, begidx, endidx) complete_states_expected_self = None - assert first_match == 'alabama' - assert manual_command_sets_app.completion_matches == list(SupportFuncProvider.states) + assert completions.to_strings() == Completions.from_values(SupportFuncProvider.states).to_strings() manual_command_sets_app.unregister_command_set(user_unrelated) manual_command_sets_app.unregister_command_set(func_provider) @@ -908,11 +900,10 @@ def test_cross_commandset_completer(manual_command_sets_app, capsys) -> None: endidx = len(line) begidx = endidx complete_states_expected_self = user_sub1 - first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app) + completions = manual_command_sets_app.complete(text, line, begidx, endidx) complete_states_expected_self = None - assert first_match == 'alabama' - assert manual_command_sets_app.completion_matches == list(SupportFuncProvider.states) + assert completions.to_strings() == Completions.from_values(SupportFuncProvider.states).to_strings() manual_command_sets_app.unregister_command_set(user_unrelated) manual_command_sets_app.unregister_command_set(user_sub1) @@ -929,12 +920,10 @@ def test_cross_commandset_completer(manual_command_sets_app, capsys) -> None: line = f'user_unrelated {text}' endidx = len(line) begidx = endidx - first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app) - out, _err = capsys.readouterr() + completions = manual_command_sets_app.complete(text, line, begidx, endidx) - assert first_match is None - assert manual_command_sets_app.completion_matches == [] - assert "Could not find CommandSet instance" in out + assert not completions + assert "Could not find CommandSet instance" in completions.completion_error manual_command_sets_app.unregister_command_set(user_unrelated) @@ -952,12 +941,10 @@ def test_cross_commandset_completer(manual_command_sets_app, capsys) -> None: line = f'user_unrelated {text}' endidx = len(line) begidx = endidx - first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app) - out, _err = capsys.readouterr() + completions = manual_command_sets_app.complete(text, line, begidx, endidx) - assert first_match is None - assert manual_command_sets_app.completion_matches == [] - assert "Could not find CommandSet instance" in out + assert not completions + assert "Could not find CommandSet instance" in completions.completion_error manual_command_sets_app.unregister_command_set(user_unrelated) manual_command_sets_app.unregister_command_set(user_sub2) @@ -986,9 +973,9 @@ def test_path_complete(manual_command_sets_app) -> None: line = f'path {text}' endidx = len(line) begidx = endidx - first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app) + completions = manual_command_sets_app.complete(text, line, begidx, endidx) - assert first_match is not None + assert completions def test_bad_subcommand() -> None: From b8809145ca63b953ea25606af23c76ea9ba63e2c Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 16 Feb 2026 02:33:36 -0500 Subject: [PATCH 16/30] Fixed tests in test_argparse_custom.py. --- cmd2/argparse_custom.py | 29 +++++++++++++++++++++++++++++ tests/test_argparse_custom.py | 8 +++----- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 4481fadba..6c64ab9b3 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -284,6 +284,7 @@ def get_items(self) -> list[CompletionItems]: from .completion import ( ChoicesProviderUnbound, CompleterUnbound, + CompletionItem, ) from .rich_utils import Cmd2RichArgparseConsole from .styles import Cmd2Style @@ -901,6 +902,34 @@ def _ArgumentParser_set_ap_completer_type(self: argparse.ArgumentParser, ap_comp setattr(argparse.ArgumentParser, 'set_ap_completer_type', _ArgumentParser_set_ap_completer_type) +############################################################################################################ +# Patch ArgumentParser._check_value to support CompletionItems as choices +############################################################################################################ +def _ArgumentParser_check_value(_self: argparse.ArgumentParser, action: argparse.Action, value: Any) -> None: # noqa: N802 + """Check_value that supports CompletionItems as choices (Custom override of ArgumentParser._check_value). + + When displaying choices, use CompletionItem.value instead of the CompletionItem instance. + + :param self: ArgumentParser instance + :param action: the action being populated + :param value: value from command line already run through conversion function by argparse + """ + # Import gettext like argparse does + from gettext import ( + gettext as _, + ) + + if action.choices is not None and value not in action.choices: + # If any choice is a CompletionItem, then display its value property. + choices = [c.value if isinstance(c, CompletionItem) else c for c in action.choices] + args = {'value': value, 'choices': ', '.join(map(repr, choices))} + msg = _('invalid choice: %(value)r (choose from %(choices)s)') + raise ArgumentError(action, msg % args) + + +setattr(argparse.ArgumentParser, '_check_value', _ArgumentParser_check_value) + + ############################################################################################################ # Patch argparse._SubParsersAction to add remove_parser function ############################################################################################################ diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index 5096d60d7..433c0d798 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -6,6 +6,7 @@ import cmd2 from cmd2 import ( + Choices, Cmd2ArgumentParser, constants, ) @@ -292,14 +293,11 @@ def test_completion_items_as_choices(capsys) -> None: """Test cmd2's patch to Argparse._check_value() which supports CompletionItems as choices. Choices are compared to CompletionItems.orig_value instead of the CompletionItem instance. """ - from cmd2.argparse_custom import ( - CompletionItem, - ) ############################################################## # Test CompletionItems with str values ############################################################## - choices = [CompletionItem("1", "Description One"), CompletionItem("2", "Two")] + choices = Choices.from_values(["1", "2"]) parser = Cmd2ArgumentParser() parser.add_argument("choices_arg", type=str, choices=choices) @@ -321,7 +319,7 @@ def test_completion_items_as_choices(capsys) -> None: ############################################################## # Test CompletionItems with int values ############################################################## - choices = [CompletionItem(1, "Description One"), CompletionItem(2, "Two")] + choices = Choices.from_values([1, 2]) parser = Cmd2ArgumentParser() parser.add_argument("choices_arg", type=int, choices=choices) From 04e96c22956f77ff306458e398e8122d30fa4a39 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 16 Feb 2026 02:38:44 -0500 Subject: [PATCH 17/30] Fixed tests in test_dynamic_complete_style.py. --- tests/test_dynamic_complete_style.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/test_dynamic_complete_style.py b/tests/test_dynamic_complete_style.py index b3bec21b5..260e885ee 100644 --- a/tests/test_dynamic_complete_style.py +++ b/tests/test_dynamic_complete_style.py @@ -2,6 +2,7 @@ from prompt_toolkit.shortcuts import CompleteStyle import cmd2 +from cmd2 import Completions class AutoStyleApp(cmd2.Cmd): @@ -11,16 +12,18 @@ def __init__(self): def do_foo(self, args): pass - def complete_foo(self, text, line, begidx, endidx): + def complete_foo(self, text, line, begidx, endidx) -> Completions: # Return 10 items - return [f'item{i}' for i in range(10) if f'item{i}'.startswith(text)] + items = [f'item{i}' for i in range(10) if f'item{i}'.startswith(text)] + return Completions.from_values(items) def do_bar(self, args): pass - def complete_bar(self, text, line, begidx, endidx): + def complete_bar(self, text, line, begidx, endidx) -> Completions: # Return 5 items - return [f'item{i}' for i in range(5) if f'item{i}'.startswith(text)] + items = [f'item{i}' for i in range(5) if f'item{i}'.startswith(text)] + return Completions.from_values(items) @pytest.fixture From e8733bc8437ebf5d9b9d841e17f60a740d83ec1f Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 16 Feb 2026 10:30:16 -0500 Subject: [PATCH 18/30] Removed extra newlines when printing exceptions during completion. --- cmd2/cmd2.py | 9 ++++++--- cmd2/pt_utils.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index bd518f5e5..c491a0551 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1488,7 +1488,7 @@ def format_exception(self, exception: BaseException) -> str: ) final_msg.append(help_msg) - console.print(final_msg, end="") + console.print(final_msg) return capture.get() @@ -1506,7 +1506,7 @@ def pexcept( method and still call `super()` without encountering unexpected keyword argument errors. """ formatted_exception = self.format_exception(exception) - self.print_to(sys.stderr, formatted_exception + "\n") + self.print_to(sys.stderr, formatted_exception) def pfeedback( self, @@ -2519,11 +2519,14 @@ def complete( # Don't display anything if the error is blank (e.g. _NoResultsError for an argument which supresses hints) if err_str: + # _NoResultsError completion hints already include a trailing "\n". + end = "" if isinstance(ex, argparse_completer._NoResultsError) else "\n" + console = ru.Cmd2GeneralConsole() with console.capture() as capture: console.print( Text(err_str, style=Cmd2Style.ERROR if ex.apply_style else ""), - end="", + end=end, ) completion_error = capture.get() return Completions(completion_error=completion_error) diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index 6f4a082f7..d7c7066ce 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -63,7 +63,7 @@ def get_completions(self, document: Document, _complete_event: object) -> Iterab ) if completions.completion_error: - print_formatted_text(ANSI(completions.completion_error + "\n")) + print_formatted_text(ANSI(completions.completion_error)) return # Print completion table if present From 5bbd0f70a634c8a9b85c12e26ed1539d8350d0c1 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 16 Feb 2026 12:11:53 -0500 Subject: [PATCH 19/30] Fixed typo and doc formatting. --- docs/features/settings.md | 9 +++++---- tests/test_argparse_completer.py | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/features/settings.md b/docs/features/settings.md index 51f031274..37d951639 100644 --- a/docs/features/settings.md +++ b/docs/features/settings.md @@ -70,11 +70,12 @@ with and indistinguishable from output generated with `cmd2.Cmd.poutput`. ### max_completion_table_items -The maximum number of items to display in a completion table. A completion table is a special -kind of completion hint which displays details about items being completed. Tab complete -the `set` command for an example. +The maximum number of items to display in a completion table. A completion table is a special kind +of completion hint which displays details about items being completed. Tab complete the `set` +command for an example. -If the number of completion suggestions exceeds `max_completion_table_items`, then no table will appear. +If the number of completion suggestions exceeds `max_completion_table_items`, then no table will +appear. ### quiet diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index a3e2f3df0..150f70cdb 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -1118,10 +1118,10 @@ def test_display_meta(ac_app, subcommand, flag, display_meta) -> None: """Test that subcommands and flags can have display_meta data.""" if flag: text = flag - line = line = f'meta {subcommand} {text}' + line = f'meta {subcommand} {text}' else: text = subcommand - line = line = f'meta {text}' + line = f'meta {text}' endidx = len(line) begidx = endidx - len(text) From b3ee3254d1ac644620512b466d3b7c2003988e84 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 16 Feb 2026 14:39:15 -0500 Subject: [PATCH 20/30] Fixed tests in test_pt_utils.py. --- tests/test_pt_utils.py | 336 ++++++++++++++++++++++++++++------------- 1 file changed, 228 insertions(+), 108 deletions(-) diff --git a/tests/test_pt_utils.py b/tests/test_pt_utils.py index 7de0462bf..6344f41b4 100644 --- a/tests/test_pt_utils.py +++ b/tests/test_pt_utils.py @@ -5,30 +5,42 @@ from unittest.mock import Mock import pytest +from prompt_toolkit.buffer import Buffer from prompt_toolkit.document import Document +import cmd2 from cmd2 import pt_utils, utils -from cmd2.argparse_custom import CompletionItem from cmd2.history import HistoryItem from cmd2.parsing import Statement +class MockSession: + """Simulates a prompt_toolkit PromptSession.""" + + def __init__(self): + # Contains the CLI text and cursor position + self.buffer = Buffer() + + # Mock the app structure: session -> app -> current_buffer + self.app = Mock() + self.app.current_buffer = self.buffer + + # Mock for cmd2.Cmd class MockCmd: def __init__(self): - self.complete = Mock() - self.completion_matches = [] - self.display_matches = [] + # Return empty completions by default + self.complete = Mock(return_value=cmd2.Completions()) + + self.always_show_hint = False self.history = [] - self.formatted_completions = '' - self.completion_hint = '' - self.completion_header = '' self.statement_parser = Mock() self.statement_parser.terminators = [';'] self.statement_parser._command_pattern = re.compile(r'\A\s*(\S*?)(\s|\Z)') self.aliases = {} self.macros = {} self.all_commands = [] + self.session = MockSession() def get_all_commands(self): return self.all_commands @@ -148,158 +160,266 @@ def test_lex_document_unclosed_quote(self, mock_cmd_app): class TestCmd2Completer: - def test_get_completions_basic(self, mock_cmd_app): - """Test basic completion without display matches.""" + def test_get_completions(self, mock_cmd_app: MockCmd, monkeypatch) -> None: + """Test get_completions with matches.""" + mock_print = Mock() + monkeypatch.setattr(pt_utils, "print_formatted_text", mock_print) + completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app)) - # Setup document - text = "foo" - line = "command foo" - cursor_position = len(line) - document = Document(line, cursor_position=cursor_position) + # Set up document + line = "" + document = Document(line, cursor_position=0) - # Setup matches - mock_cmd_app.completion_matches = ["foobar", "food"] - mock_cmd_app.display_matches = [] # Empty means use completion matches for display + # Set up matches + completion_items = [ + cmd2.CompletionItem("foo", display="Foo Display"), + cmd2.CompletionItem("bar", display="Bar Display"), + ] + cmd2_completions = cmd2.Completions(completion_items, completion_table="Table Data") + mock_cmd_app.complete.return_value = cmd2_completions # Call get_completions completions = list(completer.get_completions(document, None)) - # Verify cmd_app.complete was called correctly - # begidx = cursor_position - len(text) = 11 - 3 = 8 - mock_cmd_app.complete.assert_called_once_with(text, line=line, begidx=8, endidx=11, custom_settings=None) + # Verify completions which are sorted by display field. + assert len(completions) == len(cmd2_completions) + assert completions[0].text == "bar" + assert completions[0].display == [('', 'Bar Display')] - # Verify completions - assert len(completions) == 2 - assert completions[0].text == "foobar" - assert completions[0].start_position == -3 - # prompt_toolkit 3.0+ uses FormattedText for display - assert completions[0].display == [('', 'foobar')] + assert completions[1].text == "foo" + assert completions[1].display == [('', 'Foo Display')] - assert completions[1].text == "food" - assert completions[1].start_position == -3 - assert completions[1].display == [('', 'food')] + # Verify that only the completion table printed + assert mock_print.call_count == 1 + args, _ = mock_print.call_args + assert cmd2_completions.completion_table in str(args[0]) + + def test_get_completions_no_matches(self, mock_cmd_app: MockCmd, monkeypatch) -> None: + """Test get_completions with no matches.""" + mock_print = Mock() + monkeypatch.setattr(pt_utils, "print_formatted_text", mock_print) - def test_get_completions_with_display_matches(self, mock_cmd_app): - """Test completion with display matches.""" completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app)) - # Setup document - line = "f" - document = Document(line, cursor_position=1) + document = Document("", cursor_position=0) - # Setup matches - mock_cmd_app.completion_matches = ["foo", "bar"] - mock_cmd_app.display_matches = ["Foo Display", "Bar Display"] + # Set up matches + cmd2_completions = cmd2.Completions(completion_hint="Completion Hint") + mock_cmd_app.complete.return_value = cmd2_completions - # Call get_completions completions = list(completer.get_completions(document, None)) + assert not completions - # Verify completions - assert len(completions) == 2 - assert completions[0].text == "foo" - assert completions[0].display == [('', 'Foo Display')] + # Verify that only the completion hint printed + assert mock_print.call_count == 1 + args, _ = mock_print.call_args + assert cmd2_completions.completion_hint in str(args[0]) - assert completions[1].text == "bar" - assert completions[1].display == [('', 'Bar Display')] + def test_get_completions_always_show_hints(self, mock_cmd_app: MockCmd, monkeypatch) -> None: + """Test that get_completions respects 'always_show_hint' and prints a hint even with no matches.""" + mock_print = Mock() + monkeypatch.setattr(pt_utils, "print_formatted_text", mock_print) - def test_get_completions_mismatched_display_matches(self, mock_cmd_app): - """Test completion when display_matches length doesn't match completion_matches.""" completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app)) + document = Document("test", cursor_position=4) - document = Document("", cursor_position=0) + # Enable hint printing when there are no matches. + mock_cmd_app.always_show_hint = True - mock_cmd_app.completion_matches = ["foo", "bar"] - mock_cmd_app.display_matches = ["Foo Display"] # Length mismatch + # Set up matches + cmd2_completions = cmd2.Completions(completion_hint="Completion Hint") + mock_cmd_app.complete.return_value = cmd2_completions completions = list(completer.get_completions(document, None)) + assert not completions - # Should ignore display_matches and use completion_matches for display - assert len(completions) == 2 - assert completions[0].display == [('', 'foo')] - assert completions[1].display == [('', 'bar')] + # Verify that only the completion hint printed + assert mock_print.call_count == 1 + args, _ = mock_print.call_args + assert cmd2_completions.completion_hint in str(args[0]) + + def test_get_completions_with_error(self, mock_cmd_app: MockCmd, monkeypatch) -> None: + """Test get_completions with a completion_error.""" + mock_print = Mock() + monkeypatch.setattr(pt_utils, "print_formatted_text", mock_print) - def test_get_completions_empty(self, mock_cmd_app): - """Test completion with no matches.""" completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app)) document = Document("", cursor_position=0) - mock_cmd_app.completion_matches = [] + # Set up matches + cmd2_completions = cmd2.Completions(completion_error="Completion Error") + mock_cmd_app.complete.return_value = cmd2_completions completions = list(completer.get_completions(document, None)) + assert not completions - assert len(completions) == 0 + # Verify that only the completion error printed + assert mock_print.call_count == 1 + args, _ = mock_print.call_args + assert cmd2_completions.completion_error in str(args[0]) + + @pytest.mark.parametrize( + # search_text_offset is the starting index of the user-provided search text within a full match. + # This accounts for leading shortcuts (e.g., in '@has', the offset is 1). + ('line', 'match', 'search_text_offset'), + [ + ('has', 'has space', 0), + ('@has', '@has space', 1), + ], + ) + def test_get_completions_add_opening_quote_and_abort(self, line, match, search_text_offset, mock_cmd_app) -> None: + """Test case where adding an opening quote changes text before cursor. + + This applies when there is search text. + """ + completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app)) - def test_init_with_custom_settings(self, mock_cmd_app): - """Test initializing with custom settings.""" - mock_parser = Mock() - custom_settings = utils.CustomCompletionSettings(parser=mock_parser) - completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app), custom_settings=custom_settings) + # Set up document + document = Document(line, cursor_position=len(line)) - document = Document("", cursor_position=0) + # Set up matches + completion_items = [cmd2.CompletionItem(match)] + cmd2_completions = cmd2.Completions( + completion_items, + _add_opening_quote=True, + _search_text_offset=search_text_offset, + _quote_char='"', + ) + mock_cmd_app.complete.return_value = cmd2_completions - mock_cmd_app.completion_matches = [] + # Call get_completions + completions = list(completer.get_completions(document, None)) - list(completer.get_completions(document, None)) + # get_completions inserted an opening quote in the buffer and then aborted before returning completions + assert not completions + + @pytest.mark.parametrize( + # search_text_offset is the starting index of the user-provided search text within a full match. + # This accounts for leading shortcuts (e.g., in '@has', the offset is 1). + ('line', 'matches', 'search_text_offset', 'quote_char', 'expected'), + [ + # Single matches need opening quote, closing quote, and trailing space + ('', ['has space'], 0, '"', ['"has space" ']), + ('@', ['@has space'], 1, "'", ["@'has space' "]), + # Multiple matches only need opening quote + ('', ['has space', 'more space'], 0, '"', ['"has space', '"more space']), + ('@', ['@has space', '@more space'], 1, "'", ["@'has space", "@'more space"]), + ], + ) + def test_get_completions_add_opening_quote_and_return_results( + self, line, matches, search_text_offset, quote_char, expected, mock_cmd_app + ) -> None: + """Test case where adding an opening quote does not change text before cursor. + + This applies when search text is empty. + """ + completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app)) - mock_cmd_app.complete.assert_called_once() - assert mock_cmd_app.complete.call_args[1]['custom_settings'] == custom_settings + # Set up document + document = Document(line, cursor_position=len(line)) - def test_get_completions_with_hints(self, mock_cmd_app, monkeypatch): - """Test that hints and formatted completions are printed even with no matches.""" - mock_print = Mock() - monkeypatch.setattr(pt_utils, "print_formatted_text", mock_print) + # Set up matches + completion_items = [cmd2.CompletionItem(match) for match in matches] - completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app)) - document = Document("test", cursor_position=4) + cmd2_completions = cmd2.Completions( + completion_items, + _add_opening_quote=True, + _search_text_offset=search_text_offset, + _quote_char=quote_char, + ) + mock_cmd_app.complete.return_value = cmd2_completions - mock_cmd_app.formatted_completions = "Table Data" - mock_cmd_app.completion_hint = "Hint Text" - mock_cmd_app.completion_matches = [] - mock_cmd_app.always_show_hint = True + # Call get_completions + completions = list(completer.get_completions(document, None)) - list(completer.get_completions(document, None)) + # Compare results + completion_texts = [c.text for c in completions] + assert completion_texts == expected + + @pytest.mark.parametrize( + ('line', 'match', 'quote_char', 'end_of_line', 'expected'), + [ + # --- Unquoted search text --- + # Append a trailing space when end_of_line is True + ('ma', 'match', '', True, 'match '), + ('ma', 'match', '', False, 'match'), + # --- Quoted search text --- + # Ensure closing quotes are added + # Append a trailing space when end_of_line is True + ('"ma', '"match', '"', True, '"match" '), + ("'ma", "'match", "'", False, "'match'"), + ], + ) + def test_get_completions_allow_finalization( + self, line, match, quote_char, end_of_line, expected, mock_cmd_app: MockCmd + ) -> None: + """Test that get_completions corectly handles finalizing single matches.""" + completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app)) - assert mock_print.call_count == 2 - assert mock_cmd_app.formatted_completions == "" - assert mock_cmd_app.completion_hint == "" + # Set up document + cursor_position = len(line) if end_of_line else len(line) - 1 + document = Document(line, cursor_position=cursor_position) - def test_get_completions_with_header(self, mock_cmd_app, monkeypatch): - """Test that completion header is printed even with no matches.""" - mock_print = Mock() - monkeypatch.setattr(pt_utils, "print_formatted_text", mock_print) + # Set up matches + completion_items = [cmd2.CompletionItem(match)] + cmd2_completions = cmd2.Completions(completion_items, _quote_char=quote_char) + mock_cmd_app.complete.return_value = cmd2_completions + # Call get_completions and compare results + completions = list(completer.get_completions(document, None)) + assert completions[0].text == expected + + @pytest.mark.parametrize( + ('line', 'match', 'quote_char', 'end_of_line', 'expected'), + [ + # Do not add a trailing space or closing quote to any of the matches + ('ma', 'match', '', True, 'match'), + ('ma', 'match', '', False, 'match'), + ('"ma', '"match', '"', True, '"match'), + ("'ma", "'match", "'", False, "'match"), + ], + ) + def test_get_completions_do_not_allow_finalization( + self, line, match, quote_char, end_of_line, expected, mock_cmd_app: MockCmd + ) -> None: + """Test that get_completions does not finalize single matches when allow_finalization if False.""" completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app)) - document = Document("test", cursor_position=4) - mock_cmd_app.completion_header = "Header Text" - mock_cmd_app.completion_matches = [] + # Set up document + cursor_position = len(line) if end_of_line else len(line) - 1 + document = Document(line, cursor_position=cursor_position) - list(completer.get_completions(document, None)) + # Set up matches + completion_items = [cmd2.CompletionItem(match)] + cmd2_completions = cmd2.Completions( + completion_items, + allow_finalization=False, + _quote_char=quote_char, + ) + mock_cmd_app.complete.return_value = cmd2_completions - assert mock_print.call_count == 1 - assert mock_cmd_app.completion_header == "" + # Call get_completions and compare results + completions = list(completer.get_completions(document, None)) + assert completions[0].text == expected - def test_get_completions_completion_item_meta(self, mock_cmd_app): - """Test that CompletionItem descriptive data is used as display_meta.""" - completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app)) - document = Document("foo", cursor_position=3) + def test_init_with_custom_settings(self, mock_cmd_app: MockCmd) -> None: + """Test initializing with custom settings.""" + mock_parser = Mock() + custom_settings = utils.CustomCompletionSettings(parser=mock_parser) + completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app), custom_settings=custom_settings) - # item1 with desc, item2 without desc - item1 = CompletionItem("foobar", ["My Description"]) - item2 = CompletionItem("food", []) - mock_cmd_app.completion_matches = [item1, item2] + document = Document("", cursor_position=0) - completions = list(completer.get_completions(document, None)) + mock_cmd_app.complete.return_value = cmd2.Completions() - assert len(completions) == 2 - assert completions[0].text == "foobar" - # display_meta is converted to FormattedText - assert completions[0].display_meta == [('', 'My Description')] - assert completions[1].display_meta == [('', '')] + list(completer.get_completions(document, None)) + + mock_cmd_app.complete.assert_called_once() + assert mock_cmd_app.complete.call_args[1]['custom_settings'] == custom_settings - def test_get_completions_no_statement_parser(self, mock_cmd_app): + def test_get_completions_no_statement_parser(self, mock_cmd_app: MockCmd) -> None: """Test initialization and completion without statement_parser.""" del mock_cmd_app.statement_parser completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app)) @@ -310,7 +430,7 @@ def test_get_completions_no_statement_parser(self, mock_cmd_app): # Should still work with default delimiters mock_cmd_app.complete.assert_called_once() - def test_get_completions_custom_delimiters(self, mock_cmd_app): + def test_get_completions_custom_delimiters(self, mock_cmd_app: MockCmd) -> None: """Test that custom delimiters (terminators) are respected.""" mock_cmd_app.statement_parser.terminators = ['#'] completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app)) @@ -335,7 +455,7 @@ def test_load_history_strings(self, mock_cmd_app): """Test loading history strings yields all items in forward order.""" history = pt_utils.Cmd2History(cast(Any, mock_cmd_app)) - # Setup history items + # Set up history items # History in cmd2 is oldest to newest items = [ self.make_history_item("cmd1"), From ad8ec9e81ca78ea33fa5cf17f18a778dd6bca6c7 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 17 Feb 2026 11:03:53 -0500 Subject: [PATCH 21/30] Updated types for some functions in completion.py. --- cmd2/argparse_completer.py | 5 ++--- cmd2/completion.py | 9 +++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 208153f1f..5ad08d331 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -36,6 +36,7 @@ ) from .command_definition import CommandSet from .completion import ( + Choices, CompletionItem, Completions, all_display_numeric, @@ -697,9 +698,7 @@ def _get_raw_choices(self, arg_state: _ArgumentState) -> list[CompletionItem] | ] # Standard choices - return [ - choice if isinstance(choice, CompletionItem) else CompletionItem(choice) for choice in arg_state.action.choices - ] + return Choices.from_values(arg_state.action.choices) choices_callable: ChoicesCallable | None = arg_state.action.get_choices_callable() # type: ignore[attr-defined] return choices_callable diff --git a/cmd2/completion.py b/cmd2/completion.py index 7cb246bbd..613a36567 100644 --- a/cmd2/completion.py +++ b/cmd2/completion.py @@ -4,6 +4,7 @@ import sys from collections.abc import ( Callable, + Collection, Iterator, Sequence, ) @@ -148,13 +149,13 @@ def __post_init__(self) -> None: object.__setattr__(self, "items", tuple(unique_items)) @classmethod - def from_values(cls, values: Sequence[str], *, is_sorted: bool = False) -> Self: - """Create a completion results instance from a sequence of arbitrary objects. + def from_values(cls, values: Iterator[Any], *, is_sorted: bool = False) -> Self: + """Create a CompletionItem instance from arbitrary objects. :param values: the raw objects (e.g. strs, ints, Paths) to be converted into CompletionItems. :param is_sorted: whether the values are already in the desired order. """ - items = [CompletionItem(value=v) for v in values] + items = [v if isinstance(v, CompletionItem) else CompletionItem(value=v) for v in values] return cls(items=items, is_sorted=is_sorted) def to_strings(self) -> tuple[str, ...]: @@ -244,7 +245,7 @@ class Completions(CompletionResultsBase): _quote_char: str = "" -def all_display_numeric(items: Sequence[CompletionItem]) -> bool: +def all_display_numeric(items: Collection[CompletionItem]) -> bool: """Return True if items is non-empty and every item.display is a numeric string.""" return bool(items) and all(NUMERIC_RE.match(item.display) for item in items) From b370588e5757c4b8ad61cad615cc2c25d5b60edb Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 17 Feb 2026 11:23:17 -0500 Subject: [PATCH 22/30] Fixed type issues. --- cmd2/argparse_completer.py | 5 +++-- cmd2/completion.py | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 5ad08d331..208153f1f 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -36,7 +36,6 @@ ) from .command_definition import CommandSet from .completion import ( - Choices, CompletionItem, Completions, all_display_numeric, @@ -698,7 +697,9 @@ def _get_raw_choices(self, arg_state: _ArgumentState) -> list[CompletionItem] | ] # Standard choices - return Choices.from_values(arg_state.action.choices) + return [ + choice if isinstance(choice, CompletionItem) else CompletionItem(choice) for choice in arg_state.action.choices + ] choices_callable: ChoicesCallable | None = arg_state.action.get_choices_callable() # type: ignore[attr-defined] return choices_callable diff --git a/cmd2/completion.py b/cmd2/completion.py index 613a36567..287cfe33d 100644 --- a/cmd2/completion.py +++ b/cmd2/completion.py @@ -5,6 +5,7 @@ from collections.abc import ( Callable, Collection, + Iterable, Iterator, Sequence, ) @@ -149,7 +150,7 @@ def __post_init__(self) -> None: object.__setattr__(self, "items", tuple(unique_items)) @classmethod - def from_values(cls, values: Iterator[Any], *, is_sorted: bool = False) -> Self: + def from_values(cls, values: Iterable[Any], *, is_sorted: bool = False) -> Self: """Create a CompletionItem instance from arbitrary objects. :param values: the raw objects (e.g. strs, ints, Paths) to be converted into CompletionItems. From f2bce7140cd9e19b69f815c9d31515515b6e9097 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 17 Feb 2026 14:50:28 -0500 Subject: [PATCH 23/30] Fixed tests in test_completion.py. --- tests/test_completion.py | 771 +++++++++++++-------------------------- 1 file changed, 260 insertions(+), 511 deletions(-) diff --git a/tests/test_completion.py b/tests/test_completion.py index a16c1c10e..4a1e3e23f 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -4,6 +4,7 @@ file system paths, and shell commands. """ +import dataclasses import enum import os import sys @@ -13,10 +14,13 @@ import pytest import cmd2 -from cmd2 import utils +from cmd2 import ( + CompletionItem, + Completions, + utils, +) from .conftest import ( - complete_tester, normalize, run_cmd, ) @@ -160,7 +164,7 @@ def __init__(self) -> None: utils.Settable( 'foo', str, - description="a settable param", + description="a test settable param", settable_object=self, completer=CompletionsExample.complete_foo_val, ) @@ -169,20 +173,20 @@ def __init__(self) -> None: def do_test_basic(self, args) -> None: pass - def complete_test_basic(self, text, line, begidx, endidx): + def complete_test_basic(self, text, line, begidx, endidx) -> Completions: return self.basic_complete(text, line, begidx, endidx, food_item_strs) def do_test_delimited(self, args) -> None: pass - def complete_test_delimited(self, text, line, begidx, endidx): + def complete_test_delimited(self, text, line, begidx, endidx) -> Completions: return self.delimiter_complete(text, line, begidx, endidx, delimited_strs, '/') def do_test_sort_key(self, args) -> None: pass - def complete_test_sort_key(self, text, line, begidx, endidx): - num_strs = ['2', '11', '1'] + def complete_test_sort_key(self, text, line, begidx, endidx) -> Completions: + num_strs = ['file2', 'file11', 'file1'] return self.basic_complete(text, line, begidx, endidx, num_strs) def do_test_raise_exception(self, args) -> None: @@ -194,24 +198,23 @@ def complete_test_raise_exception(self, text, line, begidx, endidx) -> NoReturn: def do_test_multiline(self, args) -> None: pass - def complete_test_multiline(self, text, line, begidx, endidx): + def complete_test_multiline(self, text, line, begidx, endidx) -> Completions: return self.basic_complete(text, line, begidx, endidx, sport_item_strs) def do_test_no_completer(self, args) -> None: """Completing this should result in completedefault() being called""" - def complete_foo_val(self, text, line, begidx, endidx, arg_tokens): + def complete_foo_val(self, text, line, begidx, endidx, arg_tokens) -> Completions: """Supports unit testing cmd2.Cmd2.complete_set_val to confirm it passes all tokens in the set command""" - if 'param' in arg_tokens: - return ["SUCCESS"] - return ["FAIL"] + value = "SUCCESS" if 'param' in arg_tokens else "FAIL" + return Completions.from_values([value]) - def completedefault(self, *ignored): + def completedefault(self, *ignored) -> Completions: """Method called to complete an input line when no command-specific complete_*() method is available. """ - return ['default'] + return Completions.from_values(['default']) @pytest.fixture @@ -219,28 +222,28 @@ def cmd2_app(): return CompletionsExample() -def test_complete_command_single(cmd2_app) -> None: - text = 'he' +def test_command_completion(cmd2_app) -> None: + text = 'run' line = text endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - assert cmd2_app.completion_matches == ['help '] + expected = ['run_pyscript', 'run_script'] + completions = cmd2_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() -def test_complete_empty_arg(cmd2_app) -> None: - text = '' - line = f'help {text}' +def test_command_completion_nomatch(cmd2_app) -> None: + text = 'fakecommand' + line = text endidx = len(line) begidx = endidx - len(text) - expected = sorted(cmd2_app.get_visible_commands(), key=cmd2_app.default_sort_key) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + completions = cmd2_app.complete(text, line, begidx, endidx) + assert not completions - assert first_match is not None - assert cmd2_app.completion_matches == expected + # ArgparseCompleter raises a _NoResultsError in this case + assert "Hint" in completions.completion_error def test_complete_bogus_command(cmd2_app) -> None: @@ -249,23 +252,21 @@ def test_complete_bogus_command(cmd2_app) -> None: endidx = len(line) begidx = endidx - len(text) - expected = ['default '] - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - assert cmd2_app.completion_matches == expected + expected = ['default'] + completions = cmd2_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() -def test_complete_exception(cmd2_app, capsys) -> None: +def test_complete_exception(cmd2_app) -> None: text = '' line = f'test_raise_exception {text}' endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - out, _err = capsys.readouterr() + completions = cmd2_app.complete(text, line, begidx, endidx) - assert first_match is None - assert "IndexError" in out + assert not completions + assert "IndexError" in completions.completion_error def test_complete_macro(base_app, request) -> None: @@ -283,9 +284,8 @@ def test_complete_macro(base_app, request) -> None: begidx = endidx - len(text) expected = [text + 'cript.py', text + 'cript.txt', text + 'cripts' + os.path.sep] - first_match = complete_tester(text, line, begidx, endidx, base_app) - assert first_match is not None - assert base_app.completion_matches == expected + completions = base_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_default_sort_key(cmd2_app) -> None: @@ -294,75 +294,54 @@ def test_default_sort_key(cmd2_app) -> None: endidx = len(line) begidx = endidx - len(text) - # First do alphabetical sorting - cmd2_app.default_sort_key = cmd2.Cmd.ALPHABETICAL_SORT_KEY - expected = ['1', '11', '2'] - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - assert cmd2_app.completion_matches == expected - - # Now switch to natural sorting - cmd2_app.default_sort_key = cmd2.Cmd.NATURAL_SORT_KEY - expected = ['1', '2', '11'] - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - assert cmd2_app.completion_matches == expected - - -def test_cmd2_command_completion_multiple(cmd2_app) -> None: - text = 'h' - line = text - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - assert cmd2_app.completion_matches == ['help', 'history'] - + saved_sort_key = utils.DEFAULT_STR_SORT_KEY -def test_cmd2_command_completion_nomatch(cmd2_app) -> None: - text = 'fakecommand' - line = text - endidx = len(line) - begidx = endidx - len(text) + try: + # First do alphabetical sorting + utils.set_default_str_sort_key(utils.ALPHABETICAL_SORT_KEY) + expected = ['file1', 'file11', 'file2'] + completions = cmd2_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is None - assert cmd2_app.completion_matches == [] + # Now switch to natural sorting + utils.set_default_str_sort_key(utils.NATURAL_SORT_KEY) + expected = ['file1', 'file2', 'file11'] + completions = cmd2_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() + finally: + utils.set_default_str_sort_key(saved_sort_key) -def test_cmd2_help_completion_single(cmd2_app) -> None: - text = 'he' +def test_help_completion(cmd2_app) -> None: + text = 'h' line = f'help {text}' endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - - # It is at end of line, so extra space is present - assert first_match is not None - assert cmd2_app.completion_matches == ['help '] + expected = ['help', 'history'] + completions = cmd2_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() -def test_cmd2_help_completion_multiple(cmd2_app) -> None: - text = 'h' +def test_help_completion_empty_arg(cmd2_app) -> None: + text = '' line = f'help {text}' endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - assert cmd2_app.completion_matches == ['help', 'history'] + expected = cmd2_app.get_visible_commands() + completions = cmd2_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() -def test_cmd2_help_completion_nomatch(cmd2_app) -> None: +def test_help_completion_nomatch(cmd2_app) -> None: text = 'fakecommand' line = f'help {text}' endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is None + completions = cmd2_app.complete(text, line, begidx, endidx) + assert not completions def test_set_allow_style_completion(cmd2_app) -> None: @@ -373,10 +352,8 @@ def test_set_allow_style_completion(cmd2_app) -> None: begidx = endidx - len(text) expected = [val.name.lower() for val in cmd2.rich_utils.AllowStyle] - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match - assert cmd2_app.completion_matches == sorted(expected, key=cmd2_app.default_sort_key) + completions = cmd2_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_set_bool_completion(cmd2_app) -> None: @@ -387,10 +364,8 @@ def test_set_bool_completion(cmd2_app) -> None: begidx = endidx - len(text) expected = ['false', 'true'] - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match - assert cmd2_app.completion_matches == sorted(expected, key=cmd2_app.default_sort_key) + completions = cmd2_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_shell_command_completion_shortcut(cmd2_app) -> None: @@ -399,24 +374,23 @@ def test_shell_command_completion_shortcut(cmd2_app) -> None: # begin with the !. if sys.platform == "win32": text = '!calc' - expected = ['!calc.exe '] - expected_display = ['calc.exe'] + expected_item = CompletionItem('!calc.exe', display='calc.exe') else: text = '!egr' - expected = ['!egrep '] - expected_display = ['egrep'] + expected_item = CompletionItem('!egrep', display='egrep') + + expected_completions = Completions([expected_item]) line = text endidx = len(line) begidx = 0 - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - assert cmd2_app.completion_matches == expected - assert cmd2_app.display_matches == expected_display + completions = cmd2_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == expected_completions.to_strings() + assert [item.display for item in completions] == [item.display for item in expected_completions] -def test_shell_command_completion_doesnt_match_wildcards(cmd2_app) -> None: +def test_shell_command_completion_does_not_match_wildcards(cmd2_app) -> None: if sys.platform == "win32": text = 'c*' else: @@ -426,11 +400,11 @@ def test_shell_command_completion_doesnt_match_wildcards(cmd2_app) -> None: endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is None + completions = cmd2_app.complete(text, line, begidx, endidx) + assert not completions -def test_shell_command_completion_multiple(cmd2_app) -> None: +def test_shell_command_complete(cmd2_app) -> None: if sys.platform == "win32": text = 'c' expected = 'calc.exe' @@ -442,9 +416,8 @@ def test_shell_command_completion_multiple(cmd2_app) -> None: endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - assert expected in cmd2_app.completion_matches + completions = cmd2_app.complete(text, line, begidx, endidx) + assert expected in completions.to_strings() def test_shell_command_completion_nomatch(cmd2_app) -> None: @@ -453,18 +426,18 @@ def test_shell_command_completion_nomatch(cmd2_app) -> None: endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is None + completions = cmd2_app.complete(text, line, begidx, endidx) + assert not completions -def test_shell_command_completion_doesnt_complete_when_just_shell(cmd2_app) -> None: +def test_shell_command_completion_does_not_complete_when_just_shell(cmd2_app) -> None: text = '' line = f'shell {text}' endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is None + completions = cmd2_app.complete(text, line, begidx, endidx) + assert not completions def test_shell_command_completion_does_path_completion_when_after_command(cmd2_app, request) -> None: @@ -476,9 +449,9 @@ def test_shell_command_completion_does_path_completion_when_after_command(cmd2_a endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - assert cmd2_app.completion_matches == [text + '.py '] + expected = [text + '.py'] + completions = cmd2_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_shell_command_complete_in_path(cmd2_app, request) -> None: @@ -493,24 +466,13 @@ def test_shell_command_complete_in_path(cmd2_app, request) -> None: # Since this will look for directories and executables in the given path, # we expect to see the scripts dir among the results expected = os.path.join(test_dir, 'scripts' + os.path.sep) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - assert expected in cmd2_app.completion_matches - - -def test_path_completion_single_end(cmd2_app, request) -> None: - test_dir = os.path.dirname(request.module.__file__) - - text = os.path.join(test_dir, 'conftest') - line = f'shell cat {text}' - - endidx = len(line) - begidx = endidx - len(text) - assert cmd2_app.path_complete(text, line, begidx, endidx) == [text + '.py'] + completions = cmd2_app.complete(text, line, begidx, endidx) + assert expected in completions.to_strings() -def test_path_completion_multiple(cmd2_app, request) -> None: +def test_path_completion_files_and_directories(cmd2_app, request) -> None: + """Test that directories include an ending slash and files do not.""" test_dir = os.path.dirname(request.module.__file__) text = os.path.join(test_dir, 's') @@ -519,9 +481,9 @@ def test_path_completion_multiple(cmd2_app, request) -> None: endidx = len(line) begidx = endidx - len(text) - matches = cmd2_app.path_complete(text, line, begidx, endidx) expected = [text + 'cript.py', text + 'cript.txt', text + 'cripts' + os.path.sep] - assert matches == expected + completions = cmd2_app.path_complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_path_completion_nomatch(cmd2_app, request) -> None: @@ -533,7 +495,8 @@ def test_path_completion_nomatch(cmd2_app, request) -> None: endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.path_complete(text, line, begidx, endidx) == [] + completions = cmd2_app.path_complete(text, line, begidx, endidx) + assert not completions def test_default_to_shell_completion(cmd2_app, request) -> None: @@ -554,9 +517,9 @@ def test_default_to_shell_completion(cmd2_app, request) -> None: endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - assert cmd2_app.completion_matches == [text + '.py '] + expected = [text + '.py'] + completions = cmd2_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_path_completion_no_text(cmd2_app) -> None: @@ -572,9 +535,11 @@ def test_path_completion_no_text(cmd2_app) -> None: line = f'shell ls {text}' endidx = len(line) begidx = endidx - len(text) + completions_cwd = cmd2_app.path_complete(text, line, begidx, endidx) - # We have to strip off the path from the beginning since the matches are entire paths - completions_cwd = [match.replace(text, '', 1) for match in cmd2_app.path_complete(text, line, begidx, endidx)] + # To compare matches, strip off the CWD from the front of completions_cwd. + stripped_paths = [CompletionItem(value=item.text.replace(text, '', 1)) for item in completions_cwd] + completions_cwd = dataclasses.replace(completions_cwd, items=stripped_paths) # Verify that the first test gave results for entries in the cwd assert completions_no_text == completions_cwd @@ -594,9 +559,11 @@ def test_path_completion_no_path(cmd2_app) -> None: line = f'shell ls {text}' endidx = len(line) begidx = endidx - len(text) + completions_cwd = cmd2_app.path_complete(text, line, begidx, endidx) - # We have to strip off the path from the beginning since the matches are entire paths (Leave the 's') - completions_cwd = [match.replace(text[:-1], '', 1) for match in cmd2_app.path_complete(text, line, begidx, endidx)] + # To compare matches, strip off the CWD from the front of completions_cwd (leave the 's'). + stripped_paths = [CompletionItem(value=item.text.replace(text[:-1], '', 1)) for item in completions_cwd] + completions_cwd = dataclasses.replace(completions_cwd, items=stripped_paths) # Verify that the first test gave results for entries in the cwd assert completions_no_text == completions_cwd @@ -607,22 +574,23 @@ def test_path_completion_no_path(cmd2_app) -> None: def test_path_completion_cwd_is_root_dir(cmd2_app) -> None: # Change our CWD to root dir cwd = os.getcwd() - os.chdir(os.path.sep) + try: + os.chdir(os.path.sep) - text = '' - line = f'shell ls {text}' - endidx = len(line) - begidx = endidx - len(text) - completions = cmd2_app.path_complete(text, line, begidx, endidx) - - # No match should start with a slash - assert not any(match.startswith(os.path.sep) for match in completions) + text = '' + line = f'shell ls {text}' + endidx = len(line) + begidx = endidx - len(text) + completions = cmd2_app.path_complete(text, line, begidx, endidx) - # Restore CWD - os.chdir(cwd) + # No match should start with a slash + assert not any(item.text.startswith(os.path.sep) for item in completions) + finally: + # Restore CWD + os.chdir(cwd) -def test_path_completion_doesnt_match_wildcards(cmd2_app, request) -> None: +def test_path_completion_does_not_match_wildcards(cmd2_app, request) -> None: test_dir = os.path.dirname(request.module.__file__) text = os.path.join(test_dir, 'c*') @@ -632,7 +600,8 @@ def test_path_completion_doesnt_match_wildcards(cmd2_app, request) -> None: begidx = endidx - len(text) # Currently path completion doesn't accept wildcards, so will always return empty results - assert cmd2_app.path_complete(text, line, begidx, endidx) == [] + completions = cmd2_app.path_complete(text, line, begidx, endidx) + assert not completions def test_path_completion_complete_user(cmd2_app) -> None: @@ -644,10 +613,10 @@ def test_path_completion_complete_user(cmd2_app) -> None: line = f'shell fake {text}' endidx = len(line) begidx = endidx - len(text) - completions = cmd2_app.path_complete(text, line, begidx, endidx) expected = text + os.path.sep - assert expected in completions + completions = cmd2_app.path_complete(text, line, begidx, endidx) + assert expected in completions.to_strings() def test_path_completion_user_path_expansion(cmd2_app) -> None: @@ -662,49 +631,35 @@ def test_path_completion_user_path_expansion(cmd2_app) -> None: line = f'shell {cmd} {text}' endidx = len(line) begidx = endidx - len(text) - completions_tilde_slash = [match.replace(text, '', 1) for match in cmd2_app.path_complete(text, line, begidx, endidx)] + completions_tilde_slash = cmd2_app.path_complete(text, line, begidx, endidx) + + # To compare matches, strip off ~/ from the front of completions_tilde_slash. + stripped_paths = [CompletionItem(value=item.text.replace(text, '', 1)) for item in completions_tilde_slash] + completions_tilde_slash = dataclasses.replace(completions_tilde_slash, items=stripped_paths) # Run path complete on the user's home directory text = os.path.expanduser('~') + os.path.sep line = f'shell {cmd} {text}' endidx = len(line) begidx = endidx - len(text) - completions_home = [match.replace(text, '', 1) for match in cmd2_app.path_complete(text, line, begidx, endidx)] - - assert completions_tilde_slash == completions_home - - -def test_path_completion_directories_only(cmd2_app, request) -> None: - test_dir = os.path.dirname(request.module.__file__) - - text = os.path.join(test_dir, 's') - line = f'shell cat {text}' + completions_home = cmd2_app.path_complete(text, line, begidx, endidx) - endidx = len(line) - begidx = endidx - len(text) + # To compare matches, strip off user's home directory from the front of completions_home. + stripped_paths = [CompletionItem(value=item.text.replace(text, '', 1)) for item in completions_home] + completions_home = dataclasses.replace(completions_home, items=stripped_paths) - expected = [text + 'cripts' + os.path.sep] - - assert cmd2_app.path_complete(text, line, begidx, endidx, path_filter=os.path.isdir) == expected - - -def test_basic_completion_single(cmd2_app) -> None: - text = 'Pi' - line = f'list_food -f {text}' - endidx = len(line) - begidx = endidx - len(text) - - assert cmd2_app.basic_complete(text, line, begidx, endidx, food_item_strs) == ['Pizza'] + assert completions_tilde_slash == completions_home -def test_basic_completion_multiple(cmd2_app) -> None: - text = '' +def test_basic_completion(cmd2_app) -> None: + text = 'P' line = f'list_food -f {text}' endidx = len(line) begidx = endidx - len(text) - matches = sorted(cmd2_app.basic_complete(text, line, begidx, endidx, food_item_strs)) - assert matches == sorted(food_item_strs) + expected = ['Pizza', 'Potato'] + completions = cmd2_app.basic_complete(text, line, begidx, endidx, food_item_strs) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_basic_completion_nomatch(cmd2_app) -> None: @@ -713,7 +668,8 @@ def test_basic_completion_nomatch(cmd2_app) -> None: endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.basic_complete(text, line, begidx, endidx, food_item_strs) == [] + completions = cmd2_app.basic_complete(text, line, begidx, endidx, food_item_strs) + assert not completions def test_delimiter_completion_partial(cmd2_app) -> None: @@ -723,17 +679,16 @@ def test_delimiter_completion_partial(cmd2_app) -> None: endidx = len(line) begidx = endidx - len(text) - matches = cmd2_app.delimiter_complete(text, line, begidx, endidx, delimited_strs, '/') - # All matches end with the delimiter - matches.sort(key=cmd2_app.default_sort_key) - expected_matches = sorted(["/home/other user/", "/home/user/"], key=cmd2_app.default_sort_key) - - cmd2_app.display_matches.sort(key=cmd2_app.default_sort_key) - expected_display = sorted(["other user/", "user/"], key=cmd2_app.default_sort_key) + expected_items = [ + CompletionItem("/home/other user/", display="other user/"), + CompletionItem("/home/user/", display="user/"), + ] + expected_completions = Completions(expected_items) + completions = cmd2_app.delimiter_complete(text, line, begidx, endidx, delimited_strs, '/') - assert matches == expected_matches - assert cmd2_app.display_matches == expected_display + assert completions.to_strings() == expected_completions.to_strings() + assert [item.display for item in completions] == [item.display for item in expected_completions] def test_delimiter_completion_full(cmd2_app) -> None: @@ -743,17 +698,16 @@ def test_delimiter_completion_full(cmd2_app) -> None: endidx = len(line) begidx = endidx - len(text) - matches = cmd2_app.delimiter_complete(text, line, begidx, endidx, delimited_strs, '/') - # No matches end with the delimiter - matches.sort(key=cmd2_app.default_sort_key) - expected_matches = sorted(["/home/other user/maps", "/home/other user/tests"], key=cmd2_app.default_sort_key) - - cmd2_app.display_matches.sort(key=cmd2_app.default_sort_key) - expected_display = sorted(["maps", "tests"], key=cmd2_app.default_sort_key) + expected_items = [ + CompletionItem("/home/other user/maps", display="maps"), + CompletionItem("/home/other user/tests", display="tests"), + ] + expected_completions = Completions(expected_items) + completions = cmd2_app.delimiter_complete(text, line, begidx, endidx, delimited_strs, '/') - assert matches == expected_matches - assert cmd2_app.display_matches == expected_display + assert completions.to_strings() == expected_completions.to_strings() + assert [item.display for item in completions] == [item.display for item in expected_completions] def test_delimiter_completion_nomatch(cmd2_app) -> None: @@ -762,26 +716,19 @@ def test_delimiter_completion_nomatch(cmd2_app) -> None: endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.delimiter_complete(text, line, begidx, endidx, delimited_strs, '/') == [] - - -def test_flag_based_completion_single(cmd2_app) -> None: - text = 'Pi' - line = f'list_food -f {text}' - endidx = len(line) - begidx = endidx - len(text) + completions = cmd2_app.delimiter_complete(text, line, begidx, endidx, delimited_strs, '/') + assert not completions - assert cmd2_app.flag_based_complete(text, line, begidx, endidx, flag_dict) == ['Pizza'] - -def test_flag_based_completion_multiple(cmd2_app) -> None: - text = '' +def test_flag_based_completion(cmd2_app) -> None: + text = 'P' line = f'list_food -f {text}' endidx = len(line) begidx = endidx - len(text) - matches = sorted(cmd2_app.flag_based_complete(text, line, begidx, endidx, flag_dict)) - assert matches == sorted(food_item_strs) + expected = ['Pizza', 'Potato'] + completions = cmd2_app.flag_based_complete(text, line, begidx, endidx, flag_dict) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_flag_based_completion_nomatch(cmd2_app) -> None: @@ -790,7 +737,8 @@ def test_flag_based_completion_nomatch(cmd2_app) -> None: endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.flag_based_complete(text, line, begidx, endidx, flag_dict) == [] + completions = cmd2_app.flag_based_complete(text, line, begidx, endidx, flag_dict) + assert not completions def test_flag_based_default_completer(cmd2_app, request) -> None: @@ -802,9 +750,9 @@ def test_flag_based_default_completer(cmd2_app, request) -> None: endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.flag_based_complete(text, line, begidx, endidx, flag_dict, all_else=cmd2_app.path_complete) == [ - text + 'onftest.py' - ] + expected = [text + 'onftest.py'] + completions = cmd2_app.flag_based_complete(text, line, begidx, endidx, flag_dict, all_else=cmd2_app.path_complete) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_flag_based_callable_completer(cmd2_app, request) -> None: @@ -817,26 +765,21 @@ def test_flag_based_callable_completer(cmd2_app, request) -> None: begidx = endidx - len(text) flag_dict['-o'] = cmd2_app.path_complete - assert cmd2_app.flag_based_complete(text, line, begidx, endidx, flag_dict) == [text + 'onftest.py'] - -def test_index_based_completion_single(cmd2_app) -> None: - text = 'Foo' - line = f'command Pizza {text}' - endidx = len(line) - begidx = endidx - len(text) - - assert cmd2_app.index_based_complete(text, line, begidx, endidx, index_dict) == ['Football'] + expected = [text + 'onftest.py'] + completions = cmd2_app.flag_based_complete(text, line, begidx, endidx, flag_dict) + assert completions.to_strings() == Completions.from_values(expected).to_strings() -def test_index_based_completion_multiple(cmd2_app) -> None: +def test_index_based_completion(cmd2_app) -> None: text = '' line = f'command Pizza {text}' endidx = len(line) begidx = endidx - len(text) - matches = sorted(cmd2_app.index_based_complete(text, line, begidx, endidx, index_dict)) - assert matches == sorted(sport_item_strs) + expected = sport_item_strs + completions = cmd2_app.index_based_complete(text, line, begidx, endidx, index_dict) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_index_based_completion_nomatch(cmd2_app) -> None: @@ -844,7 +787,8 @@ def test_index_based_completion_nomatch(cmd2_app) -> None: line = f'command {text}' endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.index_based_complete(text, line, begidx, endidx, index_dict) == [] + completions = cmd2_app.index_based_complete(text, line, begidx, endidx, index_dict) + assert not completions def test_index_based_default_completer(cmd2_app, request) -> None: @@ -856,9 +800,9 @@ def test_index_based_default_completer(cmd2_app, request) -> None: endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.index_based_complete(text, line, begidx, endidx, index_dict, all_else=cmd2_app.path_complete) == [ - text + 'onftest.py' - ] + expected = [text + 'onftest.py'] + completions = cmd2_app.index_based_complete(text, line, begidx, endidx, index_dict, all_else=cmd2_app.path_complete) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_index_based_callable_completer(cmd2_app, request) -> None: @@ -871,7 +815,10 @@ def test_index_based_callable_completer(cmd2_app, request) -> None: begidx = endidx - len(text) index_dict[3] = cmd2_app.path_complete - assert cmd2_app.index_based_complete(text, line, begidx, endidx, index_dict) == [text + 'onftest.py'] + + expected = [text + 'onftest.py'] + completions = cmd2_app.index_based_complete(text, line, begidx, endidx, index_dict) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_tokens_for_completion_quoted(cmd2_app) -> None: @@ -932,145 +879,40 @@ def test_tokens_for_completion_quoted_punctuation(cmd2_app) -> None: assert expected_raw_tokens == raw_tokens -def test_add_opening_quote_basic_no_text(cmd2_app) -> None: - text = '' - line = f'test_basic {text}' - endidx = len(line) - begidx = endidx - len(text) - - # Any match has a space, so opening quotes are added to all - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - expected = ["'Cheese \"Pizza\"", "'Ham", "'Ham Sandwich", "'Pizza", "'Potato"] - assert cmd2_app.completion_matches == expected - - -def test_add_opening_quote_basic_nothing_added(cmd2_app) -> None: - text = 'P' - line = f'test_basic {text}' - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - assert cmd2_app.completion_matches == ['Pizza', 'Potato'] - - -def test_add_opening_quote_basic_quote_added(cmd2_app) -> None: +def test_add_opening_quote_double_quote_added(cmd2_app) -> None: text = 'Ha' line = f'test_basic {text}' endidx = len(line) begidx = endidx - len(text) - expected = sorted(['"Ham', '"Ham Sandwich'], key=cmd2_app.default_sort_key) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - assert cmd2_app.completion_matches == expected + # At least one match has a space, so quote them all + completions = cmd2_app.complete(text, line, begidx, endidx) + assert completions._add_opening_quote + assert completions._quote_char == '"' -def test_add_opening_quote_basic_single_quote_added(cmd2_app) -> None: +def test_add_opening_quote_single_quote_added(cmd2_app) -> None: text = 'Ch' line = f'test_basic {text}' endidx = len(line) begidx = endidx - len(text) - expected = ["'Cheese \"Pizza\"' "] - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - assert cmd2_app.completion_matches == expected + # At least one match contains a double quote, so quote them all with a single quote + completions = cmd2_app.complete(text, line, begidx, endidx) + assert completions._add_opening_quote + assert completions._quote_char == "'" -def test_add_opening_quote_basic_text_is_common_prefix(cmd2_app) -> None: - # This tests when the text entered is the same as the common prefix of the matches - text = 'Ham' +def test_add_opening_quote_nothing_added(cmd2_app) -> None: + text = 'P' line = f'test_basic {text}' endidx = len(line) begidx = endidx - len(text) - expected = sorted(['"Ham', '"Ham Sandwich'], key=cmd2_app.default_sort_key) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - assert cmd2_app.completion_matches == expected - - -def test_add_opening_quote_delimited_no_text(cmd2_app) -> None: - text = '' - line = f'test_delimited {text}' - endidx = len(line) - begidx = endidx - len(text) - - # Any match has a space, so opening quotes are added to all - expected_matches = sorted(['"/home/other user/', '"/home/user/'], key=cmd2_app.default_sort_key) - expected_display = sorted(["other user/", "user/"], key=cmd2_app.default_sort_key) - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - assert cmd2_app.completion_matches == expected_matches - assert cmd2_app.display_matches == expected_display - - -def test_add_opening_quote_delimited_root_portion(cmd2_app) -> None: - text = '/home/' - line = f'test_delimited {text}' - endidx = len(line) - begidx = endidx - len(text) - - # Any match has a space, so opening quotes are added to all - expected_matches = sorted(['"/home/other user/', '"/home/user/'], key=cmd2_app.default_sort_key) - expected_display = sorted(['other user/', 'user/'], key=cmd2_app.default_sort_key) - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - assert cmd2_app.completion_matches == expected_matches - assert cmd2_app.display_matches == expected_display - - -def test_add_opening_quote_delimited_final_portion(cmd2_app) -> None: - text = '/home/user/fi' - line = f'test_delimited {text}' - endidx = len(line) - begidx = endidx - len(text) - - # Any match has a space, so opening quotes are added to all - expected_matches = sorted(['"/home/user/file.txt', '"/home/user/file space.txt'], key=cmd2_app.default_sort_key) - expected_display = sorted(['file.txt', 'file space.txt'], key=cmd2_app.default_sort_key) - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - assert cmd2_app.completion_matches == expected_matches - assert cmd2_app.display_matches == expected_display - - -def test_add_opening_quote_delimited_text_is_common_prefix(cmd2_app) -> None: - # This tests when the text entered is the same as the common prefix of the matches - text = '/home/user/file' - line = f'test_delimited {text}' - endidx = len(line) - begidx = endidx - len(text) - - expected_common_prefix = '"/home/user/file' - expected_display = sorted(['file.txt', 'file space.txt'], key=cmd2_app.default_sort_key) - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - assert os.path.commonprefix(cmd2_app.completion_matches) == expected_common_prefix - assert cmd2_app.display_matches == expected_display - - -def test_add_opening_quote_delimited_space_in_prefix(cmd2_app) -> None: - # This tests when a space appears before the part of the string that is the display match - text = '/home/oth' - line = f'test_delimited {text}' - endidx = len(line) - begidx = endidx - len(text) - - expected_common_prefix = '"/home/other user/' - expected_display = ['maps', 'tests'] - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - assert os.path.commonprefix(cmd2_app.completion_matches) == expected_common_prefix - assert cmd2_app.display_matches == expected_display + # No matches have a space so don't quote them + completions = cmd2_app.complete(text, line, begidx, endidx) + assert not completions._add_opening_quote + assert not completions._quote_char def test_no_completer(cmd2_app) -> None: @@ -1079,10 +921,9 @@ def test_no_completer(cmd2_app) -> None: endidx = len(line) begidx = endidx - len(text) - expected = ['default '] - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - assert cmd2_app.completion_matches == expected + expected = ['default'] + completions = cmd2_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_wordbreak_in_command(cmd2_app) -> None: @@ -1091,9 +932,8 @@ def test_wordbreak_in_command(cmd2_app) -> None: endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is None - assert not cmd2_app.completion_matches + completions = cmd2_app.complete(text, line, begidx, endidx) + assert not completions def test_complete_multiline_on_single_line(cmd2_app) -> None: @@ -1102,12 +942,9 @@ def test_complete_multiline_on_single_line(cmd2_app) -> None: endidx = len(line) begidx = endidx - len(text) - # Any match has a space, so opening quotes are added to all - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None - - expected = ['"Basket', '"Basketball', '"Bat', '"Football', '"Space Ball'] - assert cmd2_app.completion_matches == expected + expected = ['Basket', 'Basketball', 'Bat', 'Football', 'Space Ball'] + completions = cmd2_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_complete_multiline_on_multiple_lines(cmd2_app) -> None: @@ -1120,11 +957,9 @@ def test_complete_multiline_on_multiple_lines(cmd2_app) -> None: endidx = len(line) begidx = endidx - len(text) - expected = sorted(['Bat', 'Basket', 'Basketball'], key=cmd2_app.default_sort_key) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - - assert first_match is not None - assert cmd2_app.completion_matches == expected + expected = ['Bat', 'Basket', 'Basketball'] + completions = cmd2_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() # Used by redirect_complete tests @@ -1204,22 +1039,21 @@ def test_complete_set_value(cmd2_app) -> None: endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match == "SUCCESS " - assert cmd2_app.completion_hint == "Hint:\n value a settable param\n" + expected = ["SUCCESS"] + completions = cmd2_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() + assert completions.completion_hint.strip() == "Hint:\n value a test settable param" -def test_complete_set_value_invalid_settable(cmd2_app, capsys) -> None: +def test_complete_set_value_invalid_settable(cmd2_app) -> None: text = '' line = f'set fake {text}' endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is None - - out, _err = capsys.readouterr() - assert "fake is not a settable parameter" in out + completions = cmd2_app.complete(text, line, begidx, endidx) + assert not completions + assert "fake is not a settable parameter" in completions.completion_error @pytest.fixture @@ -1229,28 +1063,15 @@ def sc_app(): return c -def test_cmd2_subcommand_completion_single_end(sc_app) -> None: - text = 'f' - line = f'base {text}' - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, sc_app) - - # It is at end of line, so extra space is present - assert first_match is not None - assert sc_app.completion_matches == ['foo '] - - -def test_cmd2_subcommand_completion_multiple(sc_app) -> None: +def test_cmd2_subcommand_completion(sc_app) -> None: text = '' line = f'base {text}' endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, sc_app) - assert first_match is not None - assert sc_app.completion_matches == ['bar', 'foo', 'sport'] + expected = ['bar', 'foo', 'sport'] + completions = sc_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_cmd2_subcommand_completion_nomatch(sc_app) -> None: @@ -1259,21 +1080,8 @@ def test_cmd2_subcommand_completion_nomatch(sc_app) -> None: endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, sc_app) - assert first_match is None - - -def test_help_subcommand_completion_single(sc_app) -> None: - text = 'base' - line = f'help {text}' - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, sc_app) - - # It is at end of line, so extra space is present - assert first_match is not None - assert sc_app.completion_matches == ['base '] + completions = sc_app.complete(text, line, begidx, endidx) + assert not completions def test_help_subcommand_completion_multiple(sc_app) -> None: @@ -1282,9 +1090,9 @@ def test_help_subcommand_completion_multiple(sc_app) -> None: endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, sc_app) - assert first_match is not None - assert sc_app.completion_matches == ['bar', 'foo', 'sport'] + expected = ['bar', 'foo', 'sport'] + completions = sc_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_help_subcommand_completion_nomatch(sc_app) -> None: @@ -1293,8 +1101,8 @@ def test_help_subcommand_completion_nomatch(sc_app) -> None: endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, sc_app) - assert first_match is None + completions = sc_app.complete(text, line, begidx, endidx) + assert not completions def test_subcommand_tab_completion(sc_app) -> None: @@ -1304,11 +1112,9 @@ def test_subcommand_tab_completion(sc_app) -> None: endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, sc_app) - - # It is at end of line, so extra space is present - assert first_match is not None - assert sc_app.completion_matches == ['Football '] + expected = ['Football'] + completions = sc_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_subcommand_tab_completion_with_no_completer(sc_app) -> None: @@ -1319,21 +1125,8 @@ def test_subcommand_tab_completion_with_no_completer(sc_app) -> None: endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, sc_app) - assert first_match is None - - -def test_subcommand_tab_completion_space_in_text(sc_app) -> None: - text = 'B' - line = f'base sport "Space {text}' - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, sc_app) - - assert first_match is not None - assert sc_app.completion_matches == ['Ball" '] - assert sc_app.display_matches == ['Space Ball'] + completions = sc_app.complete(text, line, begidx, endidx) + assert not completions #################################################### @@ -1397,30 +1190,15 @@ def scu_app(): return SubcommandsWithUnknownExample() -def test_subcmd_with_unknown_completion_single_end(scu_app) -> None: - text = 'f' - line = f'base {text}' - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, scu_app) - - print(f'first_match: {first_match}') - - # It is at end of line, so extra space is present - assert first_match is not None - assert scu_app.completion_matches == ['foo '] - - -def test_subcmd_with_unknown_completion_multiple(scu_app) -> None: +def test_subcmd_with_unknown_completion(scu_app) -> None: text = '' line = f'base {text}' endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, scu_app) - assert first_match is not None - assert scu_app.completion_matches == ['bar', 'foo', 'sport'] + expected = ['bar', 'foo', 'sport'] + completions = scu_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_subcmd_with_unknown_completion_nomatch(scu_app) -> None: @@ -1429,32 +1207,19 @@ def test_subcmd_with_unknown_completion_nomatch(scu_app) -> None: endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, scu_app) - assert first_match is None + completions = scu_app.complete(text, line, begidx, endidx) + assert not completions -def test_help_subcommand_completion_single_scu(scu_app) -> None: - text = 'base' - line = f'help {text}' - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, scu_app) - - # It is at end of line, so extra space is present - assert first_match is not None - assert scu_app.completion_matches == ['base '] - - -def test_help_subcommand_completion_multiple_scu(scu_app) -> None: +def test_help_subcommand_completion_scu(scu_app) -> None: text = '' line = f'help base {text}' endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, scu_app) - assert first_match is not None - assert scu_app.completion_matches == ['bar', 'foo', 'sport'] + expected = ['bar', 'foo', 'sport'] + completions = scu_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_help_subcommand_completion_with_flags_before_command(scu_app) -> None: @@ -1463,9 +1228,9 @@ def test_help_subcommand_completion_with_flags_before_command(scu_app) -> None: endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, scu_app) - assert first_match is not None - assert scu_app.completion_matches == ['bar', 'foo', 'sport'] + expected = ['bar', 'foo', 'sport'] + completions = scu_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_complete_help_subcommands_with_blank_command(scu_app) -> None: @@ -1474,9 +1239,8 @@ def test_complete_help_subcommands_with_blank_command(scu_app) -> None: endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, scu_app) - assert first_match is None - assert not scu_app.completion_matches + completions = scu_app.complete(text, line, begidx, endidx) + assert not completions def test_help_subcommand_completion_nomatch_scu(scu_app) -> None: @@ -1485,8 +1249,8 @@ def test_help_subcommand_completion_nomatch_scu(scu_app) -> None: endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, scu_app) - assert first_match is None + completions = scu_app.complete(text, line, begidx, endidx) + assert not completions def test_subcommand_tab_completion_scu(scu_app) -> None: @@ -1496,11 +1260,9 @@ def test_subcommand_tab_completion_scu(scu_app) -> None: endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, scu_app) - - # It is at end of line, so extra space is present - assert first_match is not None - assert scu_app.completion_matches == ['Football '] + expected = ['Football'] + completions = scu_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() def test_subcommand_tab_completion_with_no_completer_scu(scu_app) -> None: @@ -1511,18 +1273,5 @@ def test_subcommand_tab_completion_with_no_completer_scu(scu_app) -> None: endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, scu_app) - assert first_match is None - - -def test_subcommand_tab_completion_space_in_text_scu(scu_app) -> None: - text = 'B' - line = f'base sport "Space {text}' - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, scu_app) - - assert first_match is not None - assert scu_app.completion_matches == ['Ball" '] - assert scu_app.display_matches == ['Space Ball'] + completions = scu_app.complete(text, line, begidx, endidx) + assert not completions From 343fcad7d132fe9f0d3d0989e43124c58015b9a7 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 17 Feb 2026 15:43:26 -0500 Subject: [PATCH 24/30] Updated change log and docs. --- CHANGELOG.md | 11 +++++++++ docs/features/builtin_commands.md | 26 ++++++++++---------- docs/features/initialization.md | 3 +-- examples/argparse_completion.py | 29 +++++++++++++---------- examples/basic_completion.py | 7 +++--- examples/transcripts/exampleSession.txt | 2 +- examples/transcripts/transcript_regex.txt | 29 +++++++++++------------ tests/test_completion.py | 2 +- 8 files changed, 61 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cadabee0..ee19d26b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,17 @@ shell, and the option for a persistent bottom bar that can display realtime stat `cmd2.Cmd.async_alert` - Removed `cmd2.Cmd.async_refresh_prompt` and `cmd2.Cmd.need_prompt_refresh` as they are no longer needed + - `completer` functions must now return a `cmd2.Completions` object instead of `list[str]`. + - `choices_provider` functions must now return a `cmd2.Choices` object instead of `list[str]`. + - An argparse argument's `descriptive_headers` field is now called `table_header`. + - `CompletionItem.descriptive_data` is now called `CompletionItem.table_row`. + - `Cmd.default_sort_key` moved to `utils.DEFAULT_STR_SORT_KEY`. + - Moved completion state data, which previously resided in `Cmd`, into other classes. + 1. `Cmd.matches_sorted` -> `Completions.is_sorted` and `Choices.is_sorted` + 1. `Cmd.completion_hint` -> `Completions.completion_hint` + 1. `Cmd.formatted_completions` -> `Completions.completion_table` + 1. `Cmd.matches_delimited` -> `Completions.is_delimited` + 1. `Cmd.allow_appended_space/allow_closing_quote` -> `Completions.allow_finalization` - Enhancements - New `cmd2.Cmd` parameters - **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These diff --git a/docs/features/builtin_commands.md b/docs/features/builtin_commands.md index f2bc71820..d27f8a6a2 100644 --- a/docs/features/builtin_commands.md +++ b/docs/features/builtin_commands.md @@ -77,19 +77,19 @@ application: ```text (Cmd) set - Name Value Description -─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - allow_style Terminal Allow ANSI text style sequences in output (valid values: Always, Never, Terminal) - always_show_hint False Display tab completion hint even when completion suggestions print - debug False Show full traceback on exception - echo False Echo command issued into output - editor vim Program used by 'edit' - feedback_to_output False Include nonessentials in '|' and '>' results - foreground_color cyan Foreground color to use with echo command - max_completion_items 50 Maximum number of CompletionItems to display during tab completion - quiet False Don't print nonessential feedback - scripts_add_to_history True Scripts and pyscripts add commands to history - timing False Report execution times + Name Value Description +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + allow_style Terminal Allow ANSI text style sequences in output (valid values: Always, Never, Terminal) + always_show_hint False Display completion hint even when completion suggestions print + debug False Show full traceback on exception + echo False Echo command issued into output + editor vim Program used by 'edit' + feedback_to_output False Include nonessentials in '|' and '>' results + max_column_completion_results 7 Maximum number of completion results to display in a single column + max_completion_table_items 50 Maximum number of completion results allowed for a completion table to appear + quiet False Don't print nonessential feedback + scripts_add_to_history True Scripts and pyscripts add commands to history + timing False Report execution times ``` Any of these user-settable parameters can be set while running your app with the `set` command like diff --git a/docs/features/initialization.md b/docs/features/initialization.md index b6ef366d0..6700ae1b8 100644 --- a/docs/features/initialization.md +++ b/docs/features/initialization.md @@ -31,7 +31,6 @@ Here are instance attributes of `cmd2.Cmd` which developers might wish to overri - **debug**: if `True`, show full stack trace on error (Default: `False`) - **default_category**: if any command has been categorized, then all other commands that haven't been categorized will display under this section in the help output. - **default_error**: the error that prints when a non-existent command is run -- **default_sort_key**: the default key for sorting string results. Its default value performs a case-insensitive alphabetical sort. - **default_to_shell**: if `True`, attempt to run unrecognized commands as shell commands (Default: `False`) - **disabled_commands**: commands that have been disabled from use. This is to support commands that are only available during specific states of the application. This dictionary's keys are the command names and its values are DisabledCommand objects. - **doc_header**: Set the header used for the help function's listing of documented functions @@ -45,7 +44,7 @@ Here are instance attributes of `cmd2.Cmd` which developers might wish to overri - **last_result**: stores results from the last command run to enable usage of results in a Python script or interactive console. Built-in commands don't make use of this. It is purely there for user-defined commands and convenience. - **macros**: dictionary of macro names and their values - **max_column_completion_results**: The maximum number of completion results to display in a single column (Default: 7) -- **max_completion_items**: max number of CompletionItems to display during tab completion (Default: 50) +- **max_completion_table_items**: The maximum number of completion results allowed for a completion table to appear (Default: 50) - **pager**: sets the pager command used by the `Cmd.ppaged()` method for displaying wrapped output using a pager - **pager_chop**: sets the pager command used by the `Cmd.ppaged()` method for displaying chopped/truncated output using a pager - **py_bridge_name**: name by which embedded Python environments and scripts refer to the `cmd2` application by in order to call commands (Default: `app`) diff --git a/examples/argparse_completion.py b/examples/argparse_completion.py index 6b42aa5f0..fa470b06e 100755 --- a/examples/argparse_completion.py +++ b/examples/argparse_completion.py @@ -9,6 +9,7 @@ from rich.text import Text from cmd2 import ( + Choices, Cmd, Cmd2ArgumentParser, Cmd2Style, @@ -27,11 +28,11 @@ def __init__(self) -> None: super().__init__(include_ipy=True) self.sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] - def choices_provider(self) -> list[str]: + def choices_provider(self) -> Choices: """A choices provider is useful when the choice list is based on instance data of your application.""" - return self.sport_item_strs + return Choices.from_values(self.sport_item_strs) - def choices_completion_error(self) -> list[str]: + def choices_completion_error(self) -> Choices: """CompletionErrors can be raised if an error occurs while tab completing. Example use cases @@ -39,11 +40,11 @@ def choices_completion_error(self) -> list[str]: - A previous command line argument that determines the data set being completed is invalid """ if self.debug: - return self.sport_item_strs + return Choices.from_values(self.sport_item_strs) raise CompletionError("debug must be true") - def choices_completion_item(self) -> list[CompletionItem]: - """Return CompletionItem instead of strings. These give more context to what's being tab completed.""" + def choices_completion_tables(self) -> Choices: + """Return CompletionItems with completion tables. These give more context to what's being tab completed.""" fancy_item = Text.assemble( "These things can\ncontain newlines and\n", Text("styled text!!", style=Style(color=Color.BRIGHT_YELLOW, underline=True)), @@ -58,16 +59,18 @@ def choices_completion_item(self) -> list[CompletionItem]: table_item.add_row("Yes, it's true.", "CompletionItems can") table_item.add_row("even display description", "data in tables!") - items = { + item_dict = { 1: "My item", 2: "Another item", 3: "Yet another item", 4: fancy_item, 5: table_item, } - return [CompletionItem(item_id, [description]) for item_id, description in items.items()] - def choices_arg_tokens(self, arg_tokens: dict[str, list[str]]) -> list[str]: + completion_items = [CompletionItem(item_id, table_row=[description]) for item_id, description in item_dict.items()] + return Choices(items=completion_items) + + def choices_arg_tokens(self, arg_tokens: dict[str, list[str]]) -> Choices: """If a choices or completer function/method takes a value called arg_tokens, then it will be passed a dictionary that maps the command line tokens up through the one being completed to their argparse argument name. All values of the arg_tokens dictionary are lists, even if @@ -79,7 +82,7 @@ def choices_arg_tokens(self, arg_tokens: dict[str, list[str]]) -> list[str]: values.append('is {}'.format(arg_tokens['choices_provider'][0])) else: values.append('not supplied') - return values + return Choices.from_values(values) # Parser for example command example_parser = Cmd2ArgumentParser( @@ -105,10 +108,10 @@ def choices_arg_tokens(self, arg_tokens: dict[str, list[str]]) -> list[str]: help="raise a CompletionError while tab completing if debug is False", ) - # Demonstrate returning CompletionItems instead of strings + # Demonstrate use of completion table example_parser.add_argument( - '--completion_item', - choices_provider=choices_completion_item, + '--completion_table', + choices_provider=choices_completion_tables, metavar="ITEM_ID", table_header=["Description"], help="demonstrate use of CompletionItems", diff --git a/examples/basic_completion.py b/examples/basic_completion.py index 6ef72ec81..b48c3fb2f 100755 --- a/examples/basic_completion.py +++ b/examples/basic_completion.py @@ -14,6 +14,7 @@ import functools import cmd2 +from cmd2 import Completions # List of strings used with completion functions food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato'] @@ -41,7 +42,7 @@ def do_flag_based(self, statement: cmd2.Statement) -> None: """ self.poutput(f"Args: {statement.args}") - def complete_flag_based(self, text, line, begidx, endidx) -> list[str]: + def complete_flag_based(self, text, line, begidx, endidx) -> Completions: """Completion function for do_flag_based.""" flag_dict = { # Tab complete food items after -f and --food flags in command line @@ -61,7 +62,7 @@ def do_index_based(self, statement: cmd2.Statement) -> None: """Tab completes first 3 arguments using index_based_complete.""" self.poutput(f"Args: {statement.args}") - def complete_index_based(self, text, line, begidx, endidx) -> list[str]: + def complete_index_based(self, text, line, begidx, endidx) -> Completions: """Completion function for do_index_based.""" index_dict = { 1: food_item_strs, # Tab complete food items at index 1 in command line @@ -82,7 +83,7 @@ def do_raise_error(self, statement: cmd2.Statement) -> None: """Demonstrates effect of raising CompletionError.""" self.poutput(f"Args: {statement.args}") - def complete_raise_error(self, _text, _line, _begidx, _endidx) -> list[str]: + def complete_raise_error(self, _text, _line, _begidx, _endidx) -> Completions: """CompletionErrors can be raised if an error occurs while tab completing. Example use cases diff --git a/examples/transcripts/exampleSession.txt b/examples/transcripts/exampleSession.txt index 84ff1e3f6..f420792ce 100644 --- a/examples/transcripts/exampleSession.txt +++ b/examples/transcripts/exampleSession.txt @@ -8,7 +8,7 @@ debug: False echo: False editor: /.*?/ feedback_to_output: False -max_completion_items: 50 +max_completion_table_items: 50 maxrepeats: 3 quiet: False timing: False diff --git a/examples/transcripts/transcript_regex.txt b/examples/transcripts/transcript_regex.txt index 24ce70533..ae428ed6c 100644 --- a/examples/transcripts/transcript_regex.txt +++ b/examples/transcripts/transcript_regex.txt @@ -2,19 +2,18 @@ # Anything between two forward slashes, /, is interpreted as a regular expression (regex). # The regex for editor will match whatever program you use. # regexes on prompts just make the trailing space obvious -(Cmd) set - - Name Value Description -─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - allow_style Terminal Allow ANSI text style sequences in output (valid values: Always, Never, Terminal) - always_show_hint False Display tab completion hint even when completion suggestions print - debug False Show full traceback on exception - echo False Echo command issued into output - editor /.*?/ Program used by 'edit' - feedback_to_output False Include nonessentials in '|' and '>' results - max_completion_items 50 Maximum number of CompletionItems to display during tab completion - maxrepeats 3 max repetitions for speak command - quiet False Don't print nonessential feedback - scripts_add_to_history True Scripts and pyscripts add commands to history - timing False Report execution times +(Cmd) set + Name Value Description +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + allow_style Terminal Allow ANSI text style sequences in output (valid values: Always, Never, Terminal) + always_show_hint False Display completion hint even when completion suggestions print + debug True Show full traceback on exception + echo False Echo command issued into output + editor vim Program used by 'edit' + feedback_to_output False Include nonessentials in '|' and '>' results + max_column_completion_results 7 Maximum number of completion results to display in a single column + max_completion_table_items 50 Maximum number of completion results allowed for a completion table to appear + quiet False Don't print nonessential feedback + scripts_add_to_history True Scripts and pyscripts add commands to history + timing False Report execution times diff --git a/tests/test_completion.py b/tests/test_completion.py index 4a1e3e23f..26b05187c 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -288,7 +288,7 @@ def test_complete_macro(base_app, request) -> None: assert completions.to_strings() == Completions.from_values(expected).to_strings() -def test_default_sort_key(cmd2_app) -> None: +def test_default_str_sort_key(cmd2_app) -> None: text = '' line = f'test_sort_key {text}' endidx = len(line) From c851f6464d75d74bdfea4fc7fe69cf824214d753 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 17 Feb 2026 15:47:37 -0500 Subject: [PATCH 25/30] Fixed formatting in change log. --- CHANGELOG.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee19d26b4..f2cc418a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,17 +27,17 @@ shell, and the option for a persistent bottom bar that can display realtime stat `cmd2.Cmd.async_alert` - Removed `cmd2.Cmd.async_refresh_prompt` and `cmd2.Cmd.need_prompt_refresh` as they are no longer needed - - `completer` functions must now return a `cmd2.Completions` object instead of `list[str]`. - - `choices_provider` functions must now return a `cmd2.Choices` object instead of `list[str]`. - - An argparse argument's `descriptive_headers` field is now called `table_header`. - - `CompletionItem.descriptive_data` is now called `CompletionItem.table_row`. - - `Cmd.default_sort_key` moved to `utils.DEFAULT_STR_SORT_KEY`. - - Moved completion state data, which previously resided in `Cmd`, into other classes. - 1. `Cmd.matches_sorted` -> `Completions.is_sorted` and `Choices.is_sorted` - 1. `Cmd.completion_hint` -> `Completions.completion_hint` - 1. `Cmd.formatted_completions` -> `Completions.completion_table` - 1. `Cmd.matches_delimited` -> `Completions.is_delimited` - 1. `Cmd.allow_appended_space/allow_closing_quote` -> `Completions.allow_finalization` + - `completer` functions must now return a `cmd2.Completions` object instead of `list[str]`. + - `choices_provider` functions must now return a `cmd2.Choices` object instead of `list[str]`. + - An argparse argument's `descriptive_headers` field is now called `table_header`. + - `CompletionItem.descriptive_data` is now called `CompletionItem.table_row`. + - `Cmd.default_sort_key` moved to `utils.DEFAULT_STR_SORT_KEY`. + - Moved completion state data, which previously resided in `Cmd`, into other classes. + 1. `Cmd.matches_sorted` -> `Completions.is_sorted` and `Choices.is_sorted` + 1. `Cmd.completion_hint` -> `Completions.completion_hint` + 1. `Cmd.formatted_completions` -> `Completions.completion_table` + 1. `Cmd.matches_delimited` -> `Completions.is_delimited` + 1. `Cmd.allow_appended_space/allow_closing_quote` -> `Completions.allow_finalization` - Enhancements - New `cmd2.Cmd` parameters - **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These From cf9243f914bc00a92d8ff389a61a214d39f3cd96 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 17 Feb 2026 16:16:04 -0500 Subject: [PATCH 26/30] Updated argparse_custom documentation. --- cmd2/argparse_custom.py | 63 +++++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 6c64ab9b3..d3ea4e8c9 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -48,18 +48,18 @@ my_list = ['An Option', 'SomeOtherOption'] parser.add_argument('-o', '--options', choices=my_list) -``choices_provider`` - pass a function that returns choices. This is good in +``choices_provider`` - pass a function that returns a Choices object. This is good in cases where the choices are dynamically generated when the user hits tab. Example:: - def my_choices_provider(self): + def my_choices_provider(self) -> Choices: ... return my_choices parser.add_argument("arg", choices_provider=my_choices_provider) -``completer`` - pass a function that does custom completion. +``completer`` - pass a function that does custom completion and returns a Completions object. cmd2 provides a few completer methods for convenience (e.g., path_complete, delimiter_complete) @@ -107,8 +107,8 @@ def my_choices_provider(self): Example:: - def my_choices_provider(self, arg_tokens) - def my_completer(self, text, line, begidx, endidx, arg_tokens) + def my_choices_provider(self, arg_tokens) -> Choices + def my_completer(self, text, line, begidx, endidx, arg_tokens) -> Completions All values of the arg_tokens dictionary are lists, even if a particular argument expects only 1 token. Since ArgparseCompleter is for completion, @@ -117,12 +117,31 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) the command line. It is up to the developer to determine if the user entered the correct argument type (e.g. int) and validate their values. -CompletionItem Class - This class was added to help in cases where -uninformative data is being completed. For instance, completing ID -numbers isn't very helpful to a user without context. Returning a list of -CompletionItems instead of a regular string for completion results will signal -the ArgparseCompleter to output the completion results in a table of completion -tokens with descriptive data instead of just a table of tokens:: +**CompletionItem Class** + +This class represents a single completion result and what the ``Choices`` +and ``Completion`` classes contain. + +``CompletionItem`` provides the following optional metadata fields which enhance +completion results displayed to the screen. + +1. display - string for displaying the completion differently in the completion menu +2. display_meta - meta information about completion which displays in the completion menu +3. table_row - row data for completion tables + +They can also be used as argparse choices. When a ``CompletionItem`` is created, it +stores the original value (e.g. ID number) and makes it accessible through a property +called ``value``. cmd2 has patched argparse so that when evaluating choices, input +is compared to ``CompletionItem.value`` instead of the ``CompletionItem`` instance. + +**Completion Tables** + +These were added to help in cases where uninformative data is being completed. +For instance, completing ID numbers isn't very helpful to a user without context. + +Providing ``table_row`` data in your ``CompletionItem`` signals ArgparseCompleter +to output the completion results in a table with descriptive data instead of just a table +of tokens:: Instead of this: 1 2 3 @@ -139,16 +158,9 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) that value's name. The right column header is defined using the ``table_header`` parameter of add_argument(), which is a list of header names that defaults to ["Description"]. The right column values come from the -``row_data`` argument to ``CompletionItem``. It's a ``Sequence`` with the +``table_row`` argument to ``CompletionItem``. It's a ``Sequence`` with the same number of items as ``table_header``. -To use CompletionItems, just return them from your choices_provider or -completer functions. They can also be used as argparse choices. When a -CompletionItem is created, it stores the original value (e.g. ID number) and -makes it accessible through a property called orig_value. cmd2 has patched -argparse so that when evaluating choices, input is compared to -CompletionItem.orig_value instead of the CompletionItem instance. - Example:: Add an argument and define its table_header. @@ -157,22 +169,23 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) add_argument( "item_id", type=int, - choices_provider=get_items, + choices_provider=get_choices, table_header=["Item Name", "Checked Out", "Due Date"], ) - Implement the choices_provider to return CompletionItems. + Implement the choices_provider to return Choices. - def get_items(self) -> list[CompletionItems]: + def get_choices(self) -> Choices: \"\"\"choices_provider which returns CompletionItems\"\"\" # Populate CompletionItem's table_row argument. # Its item count should match that of table_header. - return [ + items = [ CompletionItem(1, table_row=["My item", True, "02/02/2022"]), CompletionItem(2, table_row=["Another item", False, ""]), CompletionItem(3, table_row=["Yet another item", False, ""]), ] + return Choices(items) This is what the user will see during completion. @@ -197,10 +210,10 @@ def get_items(self) -> list[CompletionItems]: ``table_row`` items can include Rich objects, including styled Text and Tables. To avoid printing a excessive information to the screen at once when a user -presses tab, there is a maximum threshold for the number of CompletionItems +presses tab, there is a maximum threshold for the number of ``CompletionItems`` that will be shown. Its value is defined in ``cmd2.Cmd.max_completion_table_items``. It defaults to 50, but can be changed. If the number of completion suggestions -exceeds this number, they then a completion table won't be displayed. +exceeds this number, then a completion table won't be displayed. **Patched argparse functions** From 1f49511aa8b56a26ab466a58018808de8b8dccf8 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 17 Feb 2026 17:27:23 -0500 Subject: [PATCH 27/30] Added completion test to handle word break in a quoted token. --- tests/test_completion.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_completion.py b/tests/test_completion.py index 26b05187c..17e0ed386 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -915,6 +915,27 @@ def test_add_opening_quote_nothing_added(cmd2_app) -> None: assert not completions._quote_char +def test_word_break_in_quote(cmd2_app) -> None: + """Test case where search text has a space and is in a quote.""" + + # Cmd2Completer still performs word breaks after a quote. Since space + # is word-break character, it says the search text starts at 'S' and + # passes that to the complete() function. + text = 'S' + line = 'test_basic "Ham S' + endidx = len(line) + begidx = endidx - len(text) + + # Since the search text is within an opening quote, cmd2 will rebuild + # the whole search token as 'Ham S' and match it to 'Ham Sandwich'. + # But before it returns the results back to Cmd2Completer, it removes + # anything before the original search text since this is what Cmd2Completer + # expects. Therefore the actual match text is 'Sandwich'. + expected = ["Sandwich"] + completions = cmd2_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() + + def test_no_completer(cmd2_app) -> None: text = '' line = f'test_no_completer {text}' From 883d87c0b267d698070433009c21f551d43ddedd Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 17 Feb 2026 17:57:35 -0500 Subject: [PATCH 28/30] Increased test coverage. --- cmd2/completion.py | 2 +- cmd2/pt_utils.py | 2 +- tests/test_argparse_custom.py | 18 +++++++++++++++++- tests/test_completion.py | 13 ++++++++++++- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/cmd2/completion.py b/cmd2/completion.py index 287cfe33d..671df48cb 100644 --- a/cmd2/completion.py +++ b/cmd2/completion.py @@ -21,7 +21,7 @@ overload, ) -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from .cmd2 import Cmd from .command_definition import CommandSet diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index f818f5962..0c4928f16 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -22,7 +22,7 @@ utils, ) -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from .cmd2 import Cmd diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index 433c0d798..1f0919b93 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -10,7 +10,10 @@ Cmd2ArgumentParser, constants, ) -from cmd2.argparse_custom import generate_range_error +from cmd2.argparse_custom import ( + ChoicesCallable, + generate_range_error, +) from .conftest import run_cmd @@ -75,6 +78,19 @@ def test_apcustom_no_choices_callables_when_nargs_is_0(kwargs) -> None: assert 'None of the following parameters can be used on an action that takes no arguments' in str(excinfo.value) +def test_apcustom_choices_callables_wrong_property() -> None: + """Test using the wrong property when retrieving the to_call value from a ChoicesCallable.""" + choices_callable = ChoicesCallable(is_completer=True, to_call=fake_func) + with pytest.raises(AttributeError) as excinfo: + to_call = choices_callable.choices_provider + assert 'This instance is configured as a completer' in str(excinfo.value) + + choices_callable = ChoicesCallable(is_completer=False, to_call=fake_func) + with pytest.raises(AttributeError) as excinfo: + to_call = choices_callable.completer + assert 'This instance is configured as a choices_provider' in str(excinfo.value) + + def test_apcustom_usage() -> None: usage = "A custom usage statement" parser = Cmd2ArgumentParser(usage=usage) diff --git a/tests/test_completion.py b/tests/test_completion.py index 17e0ed386..b8d497aaf 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -947,7 +947,7 @@ def test_no_completer(cmd2_app) -> None: assert completions.to_strings() == Completions.from_values(expected).to_strings() -def test_wordbreak_in_command(cmd2_app) -> None: +def test_word_break_in_command(cmd2_app) -> None: text = '' line = f'"{text}' endidx = len(line) @@ -983,6 +983,17 @@ def test_complete_multiline_on_multiple_lines(cmd2_app) -> None: assert completions.to_strings() == Completions.from_values(expected).to_strings() +def test_completions_iteration() -> None: + items = [CompletionItem(1), CompletionItem(2)] + completions = Completions(items) + + # Test __iter__ + assert list(completions) == items + + # Test __reversed__ + assert list(reversed(completions)) == items[::-1] + + # Used by redirect_complete tests class RedirCompType(enum.Enum): SHELL_CMD = (1,) From 9630d9296c3a67bb4f7236c0b360e45a4365014a Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 17 Feb 2026 18:00:41 -0500 Subject: [PATCH 29/30] Fixed linting error. --- tests/test_argparse_custom.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index 1f0919b93..e0b233ce3 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -82,12 +82,12 @@ def test_apcustom_choices_callables_wrong_property() -> None: """Test using the wrong property when retrieving the to_call value from a ChoicesCallable.""" choices_callable = ChoicesCallable(is_completer=True, to_call=fake_func) with pytest.raises(AttributeError) as excinfo: - to_call = choices_callable.choices_provider + _ = choices_callable.choices_provider assert 'This instance is configured as a completer' in str(excinfo.value) choices_callable = ChoicesCallable(is_completer=False, to_call=fake_func) with pytest.raises(AttributeError) as excinfo: - to_call = choices_callable.completer + _ = choices_callable.completer assert 'This instance is configured as a choices_provider' in str(excinfo.value) From 4b40d775e8c3bc7e113517e5a4c54be212694fc2 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 18 Feb 2026 00:16:24 -0500 Subject: [PATCH 30/30] Reformatted some imports. --- cmd2/command_definition.py | 9 +++++---- cmd2/decorators.py | 21 ++++++++------------- cmd2/history.py | 11 +++++------ cmd2/plugin.py | 8 ++------ cmd2/pt_utils.py | 5 ++++- cmd2/py_bridge.py | 4 +--- cmd2/transcript.py | 4 +--- 7 files changed, 26 insertions(+), 36 deletions(-) diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py index e7c7672a5..769d80d1c 100644 --- a/cmd2/command_definition.py +++ b/cmd2/command_definition.py @@ -1,6 +1,9 @@ """Supports the definition of commands in separate classes to be composed into cmd2.Cmd.""" -from collections.abc import Callable, Mapping +from collections.abc import ( + Callable, + Mapping, +) from typing import ( TYPE_CHECKING, TypeAlias, @@ -11,9 +14,7 @@ CLASS_ATTR_DEFAULT_HELP_CATEGORY, COMMAND_FUNC_PREFIX, ) -from .exceptions import ( - CommandSetRegistrationError, -) +from .exceptions import CommandSetRegistrationError from .utils import Settable if TYPE_CHECKING: # pragma: no cover diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 3783236c4..526826084 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -1,7 +1,10 @@ """Decorators for ``cmd2`` commands.""" import argparse -from collections.abc import Callable, Sequence +from collections.abc import ( + Callable, + Sequence, +) from typing import ( TYPE_CHECKING, Any, @@ -10,22 +13,14 @@ Union, ) -from . import ( - constants, -) -from .argparse_custom import ( - Cmd2AttributeWrapper, -) +from . import constants +from .argparse_custom import Cmd2AttributeWrapper from .command_definition import ( CommandFunc, CommandSet, ) -from .exceptions import ( - Cmd2ArgparseError, -) -from .parsing import ( - Statement, -) +from .exceptions import Cmd2ArgparseError +from .parsing import Statement if TYPE_CHECKING: # pragma: no cover import cmd2 diff --git a/cmd2/history.py b/cmd2/history.py index e2bd67df4..a9fdf85b4 100644 --- a/cmd2/history.py +++ b/cmd2/history.py @@ -2,13 +2,12 @@ import json import re -from collections import ( - OrderedDict, -) -from collections.abc import Callable, Iterable -from dataclasses import ( - dataclass, +from collections import OrderedDict +from collections.abc import ( + Callable, + Iterable, ) +from dataclasses import dataclass from typing import ( Any, overload, diff --git a/cmd2/plugin.py b/cmd2/plugin.py index 9f65824ae..91b4af858 100644 --- a/cmd2/plugin.py +++ b/cmd2/plugin.py @@ -1,12 +1,8 @@ """Classes for the cmd2 lifecycle hooks that you can register multiple callback functions/methods with.""" -from dataclasses import ( - dataclass, -) +from dataclasses import dataclass -from .parsing import ( - Statement, -) +from .parsing import Statement @dataclass diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index 0c4928f16..75ff47d45 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -1,7 +1,10 @@ """Utilities for integrating prompt_toolkit with cmd2.""" import re -from collections.abc import Callable, Iterable +from collections.abc import ( + Callable, + Iterable, +) from typing import ( TYPE_CHECKING, Any, diff --git a/cmd2/py_bridge.py b/cmd2/py_bridge.py index 56ea22539..29a77dfcb 100644 --- a/cmd2/py_bridge.py +++ b/cmd2/py_bridge.py @@ -14,9 +14,7 @@ cast, ) -from .utils import ( # namedtuple_with_defaults, - StdSim, -) +from .utils import StdSim # namedtuple_with_defaults, if TYPE_CHECKING: # pragma: no cover import cmd2 diff --git a/cmd2/transcript.py b/cmd2/transcript.py index 6cc900762..cba5067cc 100644 --- a/cmd2/transcript.py +++ b/cmd2/transcript.py @@ -22,9 +22,7 @@ class is used in cmd2.py::run_transcript_tests() from . import utils if TYPE_CHECKING: # pragma: no cover - from cmd2 import ( - Cmd, - ) + from cmd2 import Cmd class Cmd2TestCase(unittest.TestCase):