diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cadabee0..f2cc418a2 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/cmd2/__init__.py b/cmd2/__init__.py index 1313bc1a9..a87303daa 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,11 @@ CommandSet, with_default_category, ) +from .completion import ( + Choices, + CompletionItem, + Completions, +) from .constants import ( COMMAND_NAME, DEFAULT_SHORTCUTS, @@ -52,6 +56,7 @@ CustomCompletionSettings, Settable, categorize, + set_default_str_sort_key, ) __all__: list[str] = [ # noqa: RUF022 @@ -60,7 +65,6 @@ # Argparse Exports 'Cmd2ArgumentParser', 'Cmd2AttributeWrapper', - 'CompletionItem', 'register_argparse_argument_parameter', 'set_default_ap_completer_type', 'set_default_argument_parser_type', @@ -71,6 +75,10 @@ 'Statement', # Colors "Color", + # Completion + 'Choices', + 'CompletionItem', + 'Completions', # Decorators 'with_argument_list', 'with_argparser', @@ -98,4 +106,5 @@ 'CompletionMode', 'CustomCompletionSettings', 'Settable', + 'set_default_str_sort_key', ] diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 7f4a62093..208153f1f 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -1,18 +1,20 @@ -"""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. """ import argparse +import dataclasses import inspect -import numbers from collections import ( + defaultdict, deque, ) from collections.abc import Sequence from typing import ( IO, TYPE_CHECKING, + Any, cast, ) @@ -30,16 +32,19 @@ from .argparse_custom import ( ChoicesCallable, - ChoicesProviderFuncWithTokens, - CompletionItem, generate_range_error, ) from .command_definition import CommandSet +from .completion import ( + 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. @@ -47,7 +52,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: @@ -95,13 +100,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] @@ -120,8 +125,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): @@ -131,7 +136,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) @@ -140,20 +145,24 @@ 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 on this argument, then its hint text will display. - :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 + self, + parser: argparse.ArgumentParser, + cmd2_app: 'Cmd', + *, + parent_tokens: dict[str, list[str]] | None = None, ) -> None: """Create an ArgparseCompleter. @@ -170,10 +179,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 @@ -193,8 +209,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) @@ -202,12 +225,13 @@ 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 + :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,25 +247,24 @@ def complete( flag_arg_state: _ArgumentState | None = None # Non-reusable flags that we've parsed - matched_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]) + consumed_arg_values: dict[str, list[str]] = defaultdict(list) # 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 @@ -257,7 +280,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 @@ -265,52 +292,67 @@ 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: - self._update_mutex_groups(action, completed_mutex_groups, matched_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) - - # 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] = [] + 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) + + # 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, + argparse._AppendConstAction, + argparse._CountAction, + argparse._ExtendAction, + ), + ): + # 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) + consumed_arg_values[action.dest].clear() 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: @@ -332,16 +374,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 @@ -349,7 +389,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. @@ -369,7 +409,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,27 +418,46 @@ def _update_mutex_groups( self, arg_action: argparse.Action, completed_mutex_groups: dict[argparse._MutuallyExclusiveGroup, argparse.Action], - matched_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(): - matched_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( @@ -411,29 +470,38 @@ 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: set[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 # 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 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: + # 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 if ( @@ -442,39 +510,25 @@ def _handle_last_token( or skip_remaining_flags ): raise _NoResultsError(self._parser, flag_arg_state.action) - return [] # 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) - # 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)) - elif ( - not text - and not results - 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) + 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) + + completions = self._complete_arg(text, line, begidx, endidx, pos_arg_state, consumed_arg_values, cmd_set=cmd_set) # If we have results, then return them - if results: - # Don't overwrite an existing hint - if ( - not self._cmd2_app.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 + if completions: + 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 if not _single_prefix_char(text, self._parser) or skip_remaining_flags: @@ -483,38 +537,37 @@ 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. - self._cmd2_app._reset_completion_defaults() - return cast(list[str], self._complete_flags(text, line, begidx, endidx, matched_flags)) - return [] - - 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 - match_against = [] + 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: set[str]) -> Completions: + """Completion routine for a parsers unused flags.""" + # Build a list of flags that can be completed + 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) - # Build a dictionary linking actions with their matched flag names - matched_actions: dict[argparse.Action, list[str]] = {} - for flag in matches: + 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 flag in matched_flags.to_strings(): action = self._flag_to_action[flag] - matched_actions.setdefault(action, []).append(flag) + matched_actions[action].append(flag) - # For tab completion suggestions, group matched flags by action - results: list[CompletionItem] = [] + # For completion suggestions, group matched flags by action + items: list[CompletionItem] = [] for action, option_strings in matched_actions.items(): flag_text = ', '.join(option_strings) @@ -522,71 +575,68 @@ 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 + 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: list[str] | list[CompletionItem]) -> list[str]: + def _format_completions(self, arg_state: _ArgumentState, completions: Completions) -> Completions: """Format CompletionItems into hint table.""" - # 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) + # 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 + ) - items = cast(list[CompletionItem], completions) + # 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) - # Check if the data being completed have a numerical type - all_nums = all(isinstance(c.orig_value, numbers.Number) for c in items) + # Generate the hint table string + console = Cmd2GeneralConsole() + with console.capture() as capture: + console.print(hint_table, end="", soft_wrap=False) - # Sort CompletionItems before building the hint table - if not self._cmd2_app.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) - # Otherwise sort as strings - else: - items.sort(key=self._cmd2_app.default_sort_key) - self._cmd2_app.matches_sorted = True + return dataclasses.replace( + completions, + completion_table=capture.get(), + ) - # 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) - - # 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 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) - - 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 +644,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. @@ -602,14 +652,15 @@ 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: # 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. @@ -621,127 +672,109 @@ 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, - ) -> list[str]: - """Tab completion routine for an argparse argument. - - :return: list of completions - :raises CompletionError: if the completer or choices function this calls raises one. - """ - # Check if the arg provides choices to the user - 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 [] + 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() - self._cmd2_app.matches_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 [] - 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 tab 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]) - results = 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 + all_choices = list(choices_func(*args, **kwargs)) 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 tab completion on the choices - results = self._cmd2_app.basic_complete(text, line, begidx, endidx, completion_items) + 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) - 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) + 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 c74388b0c..d3ea4e8c9 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. @@ -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 -cases where the choice list is dynamically generated when the user hits tab. +``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_generated_list + return my_choices 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 and returns a Completions object. 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 @@ -107,22 +107,41 @@ 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 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 -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 @@ -135,46 +154,40 @@ 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 +``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. - -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. +``table_row`` argument to ``CompletionItem``. It's a ``Sequence`` with the +same number of items as ``table_header``. 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"], + 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\"\"\" - # CompletionItem's second argument is descriptive_data. - # Its item count should match that of descriptive_headers. - return [ - CompletionItem(1, ["My item", True, "02/02/2022"]), - CompletionItem(2, ["Another item", False, ""]), - CompletionItem(3, ["Yet another item", False, ""]), + # Populate CompletionItem's table_row argument. + # Its item count should match that of table_header. + 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 tab completion. + This is what the user will see during completion. ITEM_ID Item Name Checked Out Due Date ─────────────────────────────────────────────────────── @@ -182,7 +195,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 +207,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``. +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 will be displayed in the typical columnized format -and will not include the descriptive_data of the CompletionItems. +exceeds this number, then a completion table won't be displayed. **Patched argparse functions** @@ -210,12 +222,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 +240,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. @@ -269,16 +275,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,21 +292,18 @@ 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 ( + ChoicesProviderUnbound, + CompleterUnbound, + CompletionItem, +) 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: @@ -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 tab 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 tab 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 tab 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 tab 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 tab 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. @@ -478,44 +384,30 @@ class ChoicesCallable: def __init__( self, is_completer: bool, - to_call: CompleterFunc | ChoicesProviderFunc, + to_call: ChoicesProviderUnbound | CompleterUnbound, ) -> 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. """ 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) ############################################################################################################ @@ -525,8 +417,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' @@ -584,7 +476,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. @@ -604,7 +496,7 @@ def _action_set_choices_provider( def _action_set_completer( self: argparse.Action, - completer: CompleterFunc, + completer: CompleterUnbound, ) -> None: """Set completer of an argparse Action. @@ -623,38 +515,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) ############################################################################################################ @@ -802,10 +694,10 @@ 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, - 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. @@ -820,13 +712,12 @@ 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. - :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 @@ -917,7 +808,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) @@ -986,7 +877,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' @@ -1016,7 +907,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) @@ -1030,8 +921,7 @@ def _ArgumentParser_set_ap_completer_type(self: argparse.ArgumentParser, ap_comp 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. + When displaying choices, use CompletionItem.value instead of the CompletionItem instance. :param self: ArgumentParser instance :param action: the action being populated @@ -1042,14 +932,12 @@ def _ArgumentParser_check_value(_self: argparse.ArgumentParser, action: argparse 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) + 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) @@ -1301,9 +1189,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/cmd2.py b/cmd2/cmd2.py index 0897767ed..c491a0551 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 @@ -49,9 +50,7 @@ Iterable, Mapping, ) -from types import ( - FrameType, -) +from types import FrameType from typing import ( IO, TYPE_CHECKING, @@ -64,10 +63,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 +89,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 +98,15 @@ CommandFunc, CommandSet, ) +from .completion import ( + Choices, + ChoicesProviderUnbound, + CompleterBound, + CompleterUnbound, + CompletionItem, + Completions, + Matchable, +) from .constants import ( CLASS_ATTR_DEFAULT_HELP_CATEGORY, COMMAND_FUNC_PREFIX, @@ -279,10 +288,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]] = [] @@ -394,7 +399,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,10 +429,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 tab 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. @@ -449,7 +453,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 +542,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. @@ -643,57 +647,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 - # tab 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 - # 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 tab 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. - self.formatted_completions: str = '' - - # Used by complete() for prompt-toolkit tab completion - self.completion_matches: list[str] = [] - - # Use this list if you need to display tab 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 - # 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) @@ -931,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): @@ -1222,9 +1175,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]: - """Tab complete allow_style values.""" - return [val.name.lower() for val in ru.AllowStyle] + 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_values(styles) def allow_style_type(value: str) -> ru.AllowStyle: """Convert a string value into an ru.AllowStyle.""" @@ -1242,19 +1196,24 @@ 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, ) ) 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_table_items', + int, + "Maximum number of completion results allowed for a completion table to appear", + self, + ) ) self.add_settable( Settable( @@ -1281,7 +1240,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 @@ -1484,11 +1443,58 @@ 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, end="") + + 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) + + 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 +1502,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 +1672,6 @@ def ppaged( rich_print_kwargs=rich_print_kwargs, ) - # ----- Methods related to tab completion ----- - - def _reset_completion_defaults(self) -> None: - """Reset tab completion settings. - - Needs to be called each time prompt-toolkit runs tab 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. @@ -1770,14 +1718,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 @@ -1839,20 +1787,31 @@ def basic_complete( line: str, # noqa: ARG002 begidx: int, # noqa: ARG002 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. + match_against: Iterable[Matchable], + *, + sort: bool = True, + ) -> Completions: + """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 - :return: a list of possible tab completions + :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 """ - return [cur_match for cur_match in match_against if cur_match.startswith(text)] + 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, is_sorted=not sort) def delimiter_complete( self, @@ -1862,15 +1821,15 @@ def delimiter_complete( endidx: int, match_against: Iterable[str], delimiter: str, - ) -> list[str]: - """Perform tab completion against a list but each match is split on a delimiter. + ) -> Completions: + """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 @@ -1893,48 +1852,48 @@ 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 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: + return Completions() - # Set this to True for proper quoting of matches with spaces - self.matches_delimited = True - - # Get the common beginning for the matches - common_prefix = os.path.commonprefix(matches) - prefix_tokens = common_prefix.split(delimiter) + match_strings = basic_completions.to_strings() # Calculate what portion of the match we are completing - display_token_index = 0 - if prefix_tokens: - display_token_index = len(prefix_tokens) - 1 + common_prefix = os.path.commonprefix(match_strings) + 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: + 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 - self.allow_appended_space = False - self.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 - filtered_matches = list(unique_results.keys()) - self.display_matches = list(unique_results.values()) + items = [ + CompletionItem( + value=value, + display=display, + ) + for value, display in unique_results.items() + ] - return filtered_matches + return Completions(items, allow_finalization=allow_finalization, is_delimited=True) def flag_based_complete( self, @@ -1942,31 +1901,30 @@ def flag_based_complete( line: str, begidx: int, endidx: int, - flag_dict: dict[str, Iterable[str] | CompleterFunc], + flag_dict: dict[str, Iterable[Matchable] | CompleterBound], *, - all_else: None | Iterable[str] | CompleterFunc = None, - ) -> list[str]: - """Tab completes based on a particular flag preceding the token being completed. + all_else: None | Iterable[Matchable] | CompleterBound = None, + ) -> 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) :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 + 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 """ # 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 @@ -1975,15 +1933,15 @@ 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) + return self.basic_complete(text, line, begidx, endidx, match_against) - # Perform tab completion using a function - elif callable(match_against): - completions_matches = match_against(text, line, begidx, endidx) + # Perform completion using a function + if callable(match_against): + return match_against(text, line, begidx, endidx) - return completions_matches + return Completions() def index_based_complete( self, @@ -1991,11 +1949,11 @@ def index_based_complete( line: str, begidx: int, endidx: int, - index_dict: Mapping[int, Iterable[str] | CompleterFunc], + index_dict: Mapping[int, Iterable[Matchable] | CompleterBound], *, - all_else: Iterable[str] | CompleterFunc | None = None, - ) -> list[str]: - """Tab completes based on a fixed position in the input string. + all_else: Iterable[Matchable] | CompleterBound | None = None, + ) -> 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) :param line: the current input line with leading whitespace removed @@ -2005,34 +1963,69 @@ 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.) - 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 + 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 """ # 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 # 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] | CompleterBound | None = 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) + return self.basic_complete(text, line, begidx, endidx, match_against) + + # Perform completion using a function + if callable(match_against): + return match_against(text, line, begidx, endidx) + + return Completions() + + @staticmethod + def _complete_users(text: str, add_trailing_sep_if_dir: bool) -> Completions: + """Complete ~ and ~user strings. - # Perform tab completion using a function - elif callable(match_against): - matches = match_against(text, line, begidx, endidx) + :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 + """ + items: list[CompletionItem] = [] - return matches + # 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 + items.append(CompletionItem(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 + items.append(CompletionItem(cur_user)) + + # 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, @@ -2042,7 +2035,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 +2045,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 tab 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 +2069,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 +2080,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) @@ -2145,41 +2101,45 @@ def complete_users() -> list[str]: if path_filter is not None: matches = [c for c in matches if path_filter(c)] - if matches: - # Set this to True for proper quoting of paths with spaces - self.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 - - # Sort the matches before any trailing slashes are added - matches.sort(key=self.default_sort_key) - self.matches_sorted = True - - # 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 - self.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 - - # 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] + 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 matches + 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) -> 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,25 +2148,26 @@ 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 Completions object """ - # 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 [] + 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) + 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( 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]: - """First tab completion function for all commands, called by complete(). + 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 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) @@ -2215,13 +2176,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 tab 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 +2205,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,12 +2214,12 @@ 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 - # 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 @@ -2277,9 +2238,9 @@ 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 [] + return Completions() # Call the command's completer function return compfunc(text, line, begidx, endidx) @@ -2301,7 +2262,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 +2270,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 +2281,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,9 +2306,10 @@ 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 + completer_func: CompleterBound if custom_settings is None: # Check if a macro was entered if command in self.macros: @@ -2411,7 +2374,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 @@ -2426,191 +2389,150 @@ 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) + completions = self._redirect_complete(text, line, begidx, endidx, completer_func) + if not completions: + return Completions() - if self.completion_matches: - # Eliminate duplicates - self.completion_matches = utils.remove_duplicates(self.completion_matches) - self.display_matches = utils.remove_duplicates(self.display_matches) + _add_opening_quote = False + _quote_char = completion_token_quote - 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. - import copy + # Check if we need to add an opening quote + if not completion_token_quote: + matches = completions.to_strings() - self.display_matches = copy.copy(self.completion_matches) + if any(' ' in match for match in matches): + _add_opening_quote = True - # Check if we need to add an opening quote - if not completion_token_quote: - add_quote = False + # Determine best quote (single vs double) based on text content + _quote_char = "'" if any('"' in t for t in matches) else '"' - # This is the tab completion text that will appear on the command line. - common_prefix = os.path.commonprefix(self.completion_matches) - - if self.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): - add_quote = True - - # If there is a tab 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 - - 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 '"' - - 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 - elif text_to_remove: - self.completion_matches = [match.replace(text_to_remove, '', 1) for match in self.completion_matches] + # Check if we need to remove text from the beginning of completions + elif text_to_remove: + new_items = [ + dataclasses.replace( + item, + text=item.text.replace(text_to_remove, '', 1), + ) + for item in completions + ] + completions = dataclasses.replace(completions, items=new_items) - # 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 + return dataclasses.replace(completions, _add_opening_quote=_add_opening_quote, _quote_char=_quote_char) 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. + ) -> Completions: + """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 - :return: the next possible completion for text or None + :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 Completions object """ 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 + # 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) - - 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] + # 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) - # 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] += ' ' + completions = self._perform_completion(text, line, begidx, endidx, custom_settings) - # 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 + # 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), + ) - # 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 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[state] - except IndexError: - return None + return completions # noqa: TRY300 except CompletionError as ex: - # Don't print error and redraw the prompt unless the error has length err_str = str(ex) + completion_error = "" + + # Don't display anything if the error is blank (e.g. _NoResultsError for an argument which supresses hints) 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 + # _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, + ) + completion_error = capture.get() + return Completions(completion_error=completion_error) 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 - 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 + formatted_exception = self.format_exception(ex) + return Completions(completion_error=formatted_exception) def in_script(self) -> bool: """Return whether a text script is running.""" @@ -2645,59 +2567,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 tab 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.""" @@ -3028,7 +2948,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 @@ -3365,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()) @@ -3385,37 +3305,36 @@ 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. - 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() """ - self._reset_completion_defaults() with self._in_prompt_lock: self._in_prompt = True try: @@ -3614,7 +3533,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." ), ) @@ -3625,7 +3544,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', @@ -3683,8 +3602,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 @@ -3725,8 +3644,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 @@ -3739,7 +3658,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: @@ -3773,18 +3699,16 @@ def macro_arg_complete( line: str, begidx: int, endidx: int, - ) -> list[str]: - """Tab completes arguments to a macro. + ) -> Completions: + """Completes arguments to a macro. 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 :param endidx: the ending index of the prefix text - :return: a list of possible tab completions + :return: a Completions object """ return self.path_complete(text, line, begidx, endidx) @@ -3857,8 +3781,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) @@ -3868,7 +3792,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', @@ -3969,8 +3893,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 @@ -4011,8 +3935,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 @@ -4025,7 +3949,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: @@ -4049,7 +3980,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()) @@ -4059,16 +3990,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']) @@ -4083,10 +4014,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]] = {} @@ -4151,7 +4082,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 @@ -4368,7 +4299,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 @@ -4458,7 +4389,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.", @@ -4473,27 +4404,27 @@ 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 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() # 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, @@ -4572,7 +4503,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, @@ -4685,7 +4616,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 @@ -4694,7 +4625,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/command_definition.py b/cmd2/command_definition.py index 963df24d7..769d80d1c 100644 --- a/cmd2/command_definition.py +++ b/cmd2/command_definition.py @@ -1,8 +1,12 @@ """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, TypeVar, ) @@ -10,19 +14,15 @@ CLASS_ATTR_DEFAULT_HELP_CATEGORY, COMMAND_FUNC_PREFIX, ) -from .exceptions import ( - CommandSetRegistrationError, -) -from .utils import ( - Settable, -) +from .exceptions import CommandSetRegistrationError +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 new file mode 100644 index 000000000..671df48cb --- /dev/null +++ b/cmd2/completion.py @@ -0,0 +1,297 @@ +"""Provides classes and functions related to completion.""" + +import re +import sys +from collections.abc import ( + Callable, + Collection, + Iterable, + Iterator, + Sequence, +) +from dataclasses import ( + dataclass, + field, +) +from typing import ( + TYPE_CHECKING, + Any, + TypeAlias, + cast, + overload, +) + +if TYPE_CHECKING: # pragma: no cover + from .cmd2 import Cmd + from .command_definition import CommandSet + +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + +from rich.protocol import is_renderable + +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, +) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class CompletionItem: + """A single completion result.""" + + # 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) + + # The actual string that will be inserted into the command line. + # If not provided, it defaults to str(value). + text: str = "" + + # Optional string for displaying the completion differently in the completion menu. + display: str = "" + + # Optional meta information about completion which displays in the completion menu. + display_meta: str = "" + + # 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) + + 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)) + + # Ensure display is never blank. + if not self.display: + object.__setattr__(self, "display", self.text) + + # 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) + + # 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 + + 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_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. + :param is_sorted: whether the values are already in the desired order. + """ + 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, ...]: + """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: + """Return True if there are items, False otherwise.""" + return bool(self.items) + + def __len__(self) -> int: + """Return the number of items.""" + return len(self.items) + + def __contains__(self, item: object) -> bool: + """Return True if the item is present in the collection.""" + return item in self.items + + def __iter__(self) -> Iterator[CompletionItem]: + """Allow the collection to be used in loops or comprehensions.""" + return iter(self.items) + + def __reversed__(self) -> Iterator[CompletionItem]: + """Allow the collection to be iterated in reverse order using reversed().""" + return reversed(self.items) + + @overload + def __getitem__(self, index: int) -> CompletionItem: ... + + @overload + def __getitem__(self, index: slice) -> tuple[CompletionItem, ...]: ... + + 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] + + +@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.""" + + +@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 = "" + + # 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 = "" + + # 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 + + # 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 + + ##################################################################### + # 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: 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) + + +############################################# +# choices_provider function types +############################################# + +# Represents the parsed tokens from argparse during completion +ArgTokens: TypeAlias = dict[str, list[str]] + +# 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] +) + +# 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 +# against their 'text' member. +Matchable: TypeAlias = str | CompletionItem diff --git a/cmd2/constants.py b/cmd2/constants.py index 1ecd19374..f89a8dfbf 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/decorators.py b/cmd2/decorators.py index de4bc2e50..526826084 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -1,30 +1,26 @@ """Decorators for ``cmd2`` commands.""" import argparse -from collections.abc import Callable, Sequence +from collections.abc import ( + Callable, + Sequence, +) from typing import ( TYPE_CHECKING, Any, + TypeAlias, TypeVar, 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 @@ -61,10 +57,9 @@ 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 = Callable[[CommandParent, Statement | str], bool | None] +RawCommandFuncOptionalBoolReturn: TypeAlias = Callable[[CommandParent, Statement | str], bool | None] ########################## @@ -113,16 +108,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 +188,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] @@ -220,7 +217,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, @@ -354,7 +351,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/exceptions.py b/cmd2/exceptions.py index 052c93eed..5b25aefb1 100644 --- a/cmd2/exceptions.py +++ b/cmd2/exceptions.py @@ -25,16 +25,12 @@ class CommandSetRegistrationError(Exception): class CompletionError(Exception): - """Raised during tab 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. + """Raised during completion operations to report any sort of error you want printed. 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 """ def __init__(self, *args: Any, apply_style: bool = True) -> None: 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/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/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 c98d81f0f..75ff47d45 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -1,15 +1,16 @@ """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, ) -from prompt_toolkit import ( - print_formatted_text, -) +from prompt_toolkit import print_formatted_text from prompt_toolkit.completion import ( Completer, Completion, @@ -18,16 +19,13 @@ 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, - rich_utils, utils, ) -from .argparse_custom import CompletionItem -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from .cmd2 import Cmd @@ -67,55 +65,73 @@ 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) - - # 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 = "" + completions = self.cmd_app.complete( + text, line=line, begidx=begidx, endidx=endidx, custom_settings=self.custom_settings + ) - # 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 = "" + if completions.completion_error: + print_formatted_text(ANSI(completions.completion_error)) + return - matches = self.cmd_app.completion_matches + # 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 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): + print_formatted_text(ANSI(completions.completion_hint)) - if not matches: + if not completions: return - # Now we iterate over self.cmd_app.completion_matches and self.cmd_app.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) - - for i, match in enumerate(matches): - display = 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). + # 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: + # 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) + 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( + match_text, + start_position=start_position, + display=item.display, + display_meta=item.display_meta, + ) class Cmd2History(History): 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): diff --git a/cmd2/utils.py b/cmd2/utils.py index 367debd7a..342dedec7 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 ( - ChoicesProviderFunc, - CompleterFunc, +from .completion import ( + Choices, + ChoicesProviderUnbound, + CompleterUnbound, ) if TYPE_CHECKING: # pragma: no cover - import cmd2 # noqa: F401 + from .decorators import CommandParent PopenTextIO = subprocess.Popen[str] else: @@ -77,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. @@ -89,7 +90,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,22 +106,22 @@ 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]: + def get_bool_choices(_cmd2_self: "CommandParent") -> Choices: """Tab complete lowercase boolean values.""" - return ['true', 'false'] + return Choices.from_values(['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: @@ -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. @@ -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/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/docs/features/settings.md b/docs/features/settings.md index 02ee3399a..37d951639 100644 --- a/docs/features/settings.md +++ b/docs/features/settings.md @@ -68,14 +68,14 @@ 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..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,12 +108,12 @@ 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", - descriptive_headers=["Description"], + 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/conftest.py b/tests/conftest.py index 666c4c016..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, 0, 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_argparse_completer.py b/tests/test_argparse_completer.py index 8e069530d..150f70cdb 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) @@ -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') @@ -105,7 +106,7 @@ def do_pos_and_flag(self, args: argparse.Namespace) -> None: ############################################################################################################ STR_METAVAR = "HEADLESS" TUPLE_METAVAR = ('arg1', 'others') - CUSTOM_DESC_HEADERS = ("Custom Headers",) + 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 +114,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() @@ -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, - descriptive_headers=CUSTOM_DESC_HEADERS, + 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, ) @@ -192,13 +193,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 +286,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) @@ -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,9 +341,29 @@ 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(): +def ac_app() -> ArgparseCompleterTester: return ArgparseCompleterTester() @@ -362,10 +383,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,213 +395,177 @@ 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'), + # 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, completion_matches, display_matches) -> 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( - ('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,67 +576,34 @@ 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: - # Generate line were preceding positionals are already filled +def test_autocomp_positional_choices_completion(ac_app, pos, text, expected) -> None: + # Generate line where 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 - - -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 + completions = ac_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() @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 +611,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 +638,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 +650,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) - - lines = ac_app.formatted_completions.splitlines() + completions = ac_app.complete(text, line, begidx, endidx) + assert len(completions) == len(ac_app.completion_item_choices) + lines = completions.completion_table.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,37 +679,34 @@ 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 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_table) -> None: # Create aliases for i in range(num_aliases): run_cmd(ac_app, f'alias create fake_alias{i} help') @@ -775,25 +718,13 @@ def test_max_completion_items(ac_app, num_aliases, show_description) -> None: 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 +747,9 @@ def test_max_completion_items(ac_app, num_aliases, show_description) -> None: ('--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 +767,21 @@ def test_max_completion_items(ac_app, num_aliases, show_description) -> None: ('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 +817,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) - - out, _err = capsys.readouterr() - assert is_error == all(x in out for x in ["Error: argument", "expected"]) + completions = ac_app.complete(text, line, begidx, endidx) + assert is_error == all(x in completions.completion_error 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}' + line = f'choices --table_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 "TABLE_HEADER" in normalize(completions.completion_table)[0] # Test when metavar is a string text = '' @@ -918,8 +842,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 +852,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 +861,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,32 +871,32 @@ 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_descriptive_headers(ac_app) -> None: +def test_completion_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}' + line = f'choices --table_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] + 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 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] + completions = ac_app.complete(text, line, begidx, endidx) + assert DEFAULT_TABLE_HEADER[0] in normalize(completions.completion_table)[0] @pytest.mark.parametrize( @@ -1001,30 +925,28 @@ def test_completion_items_descriptive_headers(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 +958,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 +978,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 +994,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 +1011,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 +1089,45 @@ 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() + + +@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 = f'meta {subcommand} {text}' + else: + text = subcommand + 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 @@ -1272,13 +1217,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 +1239,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 +1258,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 +1273,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: @@ -1347,33 +1292,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) diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index 5096d60d7..e0b233ce3 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -6,10 +6,14 @@ import cmd2 from cmd2 import ( + Choices, Cmd2ArgumentParser, constants, ) -from cmd2.argparse_custom import generate_range_error +from cmd2.argparse_custom import ( + ChoicesCallable, + generate_range_error, +) from .conftest import run_cmd @@ -74,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: + _ = 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: + _ = 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) @@ -292,14 +309,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 +335,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) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index bde06e33d..d5256661f 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_values(['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() @@ -3722,12 +3721,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 @@ -3933,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) 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: diff --git a/tests/test_completion.py b/tests/test_completion.py index a16c1c10e..b8d497aaf 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,86 +284,64 @@ 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: +def test_default_str_sort_key(cmd2_app) -> None: text = '' line = f'test_sort_key {text}' 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 + saved_sort_key = utils.DEFAULT_STR_SORT_KEY + 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() -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'] + # 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_command_completion_nomatch(cmd2_app) -> None: - text = 'fakecommand' - line = text - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is None - assert cmd2_app.completion_matches == [] - - -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 - + completions_home = cmd2_app.path_complete(text, line, begidx, endidx) -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}' + # 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) - endidx = len(line) - begidx = endidx - len(text) - - 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) - - assert cmd2_app.flag_based_complete(text, line, begidx, endidx, flag_dict) == ['Pizza'] + completions = cmd2_app.delimiter_complete(text, line, begidx, endidx, delimited_strs, '/') + assert not completions -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,61 @@ 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) + # 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 - 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_word_break_in_quote(cmd2_app) -> None: + """Test case where search text has a space and is in a quote.""" -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}' + # 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) - # 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 + # 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: @@ -1079,21 +942,19 @@ 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: +def test_word_break_in_command(cmd2_app) -> None: text = '' line = f'"{text}' 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 +963,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 +978,20 @@ 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) + expected = ['Bat', 'Basket', 'Basketball'] + completions = cmd2_app.complete(text, line, begidx, endidx) + assert completions.to_strings() == Completions.from_values(expected).to_strings() + + +def test_completions_iteration() -> None: + items = [CompletionItem(1), CompletionItem(2)] + completions = Completions(items) - assert first_match is not None - assert cmd2_app.completion_matches == expected + # Test __iter__ + assert list(completions) == items + + # Test __reversed__ + assert list(reversed(completions)) == items[::-1] # Used by redirect_complete tests @@ -1204,22 +1071,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 +1095,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 +1112,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 +1122,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 +1133,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 +1144,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 +1157,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 +1222,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 +1239,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 - - -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) + completions = scu_app.complete(text, line, begidx, endidx) + assert not completions - # 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 +1260,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 +1271,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 +1281,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 +1292,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 +1305,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 diff --git a/tests/test_dynamic_complete_style.py b/tests/test_dynamic_complete_style.py index f6160c3f4..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 @@ -34,11 +37,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 +50,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 1af5b5b89..78a2d3480 100644 --- a/tests/test_pt_utils.py +++ b/tests/test_pt_utils.py @@ -5,24 +5,35 @@ 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.shortcuts = [] @@ -30,6 +41,7 @@ def __init__(self): self.aliases = {} self.macros = {} self.all_commands = [] + self.session = MockSession() def get_all_commands(self): return self.all_commands @@ -168,158 +180,266 @@ def test_lex_document_shortcut(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, 0, 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)) @@ -330,7 +450,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)) @@ -340,7 +460,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: @@ -355,7 +475,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"),