diff --git a/CHANGELOG.md b/CHANGELOG.md index 82d978f5d..6d3b0c19b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,26 @@ +## 0.8.3 (TBD) +* Bug Fixes + * Fixed ``help`` command not calling functions for help topics + * Fixed not being able to use quoted paths when redirecting with ``<`` and ``>`` + +* Enhancements + * Tab completion has been overhauled and now supports completion of strings with quotes and spaces. + * Tab completion will automatically add an opening quote if a string with a space is completed. + * Added ``delimiter_complete`` function for tab completing delimited strings + * Added more control over tab completion behavior including the following flags. The use of these flags is documented in cmd2.py + * ``allow_appended_space`` + * ``allow_closing_quote`` + +* Attribute Changes (Breaks backward compatibility) + * ``exclude_from_help`` is now called ``hidden_commands`` since these commands are hidden from things other than help, including tab completion + * This list also no longer takes the function names of commands (``do_history``), but instead uses the command names themselves (``history``) + * ``excludeFromHistory`` is now called ``exclude_from_history`` + * ``cmd_with_subs_completer()`` no longer takes an argument called ``base``. Adding tab completion to subcommands has been simplified to declaring it in the + subcommand parser's default settings. This easily allows arbitrary completers like path_complete to be used. + See [subcommands.py](https://github.com/python-cmd2/cmd2/blob/master/examples/subcommands.py) for an example of how to use + tab completion in subcommands. In addition, the docstring for ``cmd_with_subs_completer()`` offers more details. + + ## 0.8.2 (March 21, 2018) * Bug Fixes @@ -14,7 +37,7 @@ * See [alias_startup.py](https://github.com/python-cmd2/cmd2/blob/master/examples/alias_startup.py) for an example * Added a default SIGINT handler which terminates any open pipe subprocesses and re-raises a KeyboardInterrupt * For macOS, will load the ``gnureadline`` module if available and ``readline`` if not - + ## 0.8.1 (March 9, 2018) * Bug Fixes @@ -56,7 +79,7 @@ * See the [Argument Processing](http://cmd2.readthedocs.io/en/latest/argument_processing.html) section of the documentation for more information on these decorators * Alternatively, see the [argparse_example.py](https://github.com/python-cmd2/cmd2/blob/master/examples/argparse_example.py) and [arg_print.py](https://github.com/python-cmd2/cmd2/blob/master/examples/arg_print.py) examples - * Added support for Argpasre sub-commands when using the **with_argument_parser** or **with_argparser_and_unknown_args** decorators + * Added support for Argparse sub-commands when using the **with_argument_parser** or **with_argparser_and_unknown_args** decorators * See [subcommands.py](https://github.com/python-cmd2/cmd2/blob/master/examples/subcommands.py) for an example of how to use subcommands * Tab-completion of sub-command names is automatically supported * The **__relative_load** command is now hidden from the help menu by default diff --git a/README.md b/README.md index 49466e6ba..9ccb4d61a 100755 --- a/README.md +++ b/README.md @@ -82,10 +82,10 @@ Instructions for implementing each feature follow. - Searchable command history - All commands will automatically be tracked in the session's history, unless the command is listed in Cmd's excludeFromHistory attribute. + All commands will automatically be tracked in the session's history, unless the command is listed in Cmd's exclude_from_history attribute. The history is accessed through the `history` command. If you wish to exclude some of your custom commands from the history, append their names - to the list at `Cmd.ExcludeFromHistory`. + to the list at `Cmd.exclude_from_history`. - Load commands from file, save to file, edit commands in file diff --git a/cmd2.py b/cmd2.py index bf18cdcb5..eb7ec104d 100755 --- a/cmd2.py +++ b/cmd2.py @@ -27,6 +27,7 @@ import cmd import codecs import collections +import copy import datetime import functools import glob @@ -44,9 +45,41 @@ import unittest from code import InteractiveConsole +try: + from enum34 import Enum +except ImportError: + from enum import Enum + import pyparsing import pyperclip +# Collection is a container that is sizable and iterable +# It was introduced in Python 3.6. We will try to import it, otherwise use our implementation +try: + from collections.abc import Collection +except ImportError: + + if six.PY3: + from collections.abc import Sized, Iterable, Container + else: + from collections import Sized, Iterable, Container + + # noinspection PyAbstractClass + class Collection(Sized, Iterable, Container): + + __slots__ = () + + # noinspection PyPep8Naming + @classmethod + def __subclasshook__(cls, C): + if cls is Collection: + if any("__len__" in B.__dict__ for B in C.__mro__) and \ + any("__iter__" in B.__dict__ for B in C.__mro__) and \ + any("__contains__" in B.__dict__ for B in C.__mro__): + return True + return NotImplemented + + # Newer versions of pyperclip are released as a single file, but older versions had a more complicated structure try: from pyperclip.exceptions import PyperclipException @@ -100,6 +133,35 @@ except ImportError: pass + +# Check what implementation of readline we are using +class RlType(Enum): + GNU = 1 + PYREADLINE = 2 + NONE = 3 + + +rl_type = RlType.NONE + +if 'pyreadline' in sys.modules: + rl_type = RlType.PYREADLINE + + # Save the original pyreadline display completion function since we need to override it and restore it + # noinspection PyProtectedMember + orig_pyreadline_display = readline.rl.mode._display_completions + +elif 'gnureadline' in sys.modules or 'readline' in sys.modules: + rl_type = RlType.GNU + + # Load the readline lib so we can make changes to it + import ctypes + readline_lib = ctypes.CDLL(readline.__file__) + + # Save address that rl_basic_quote_characters is pointing to since we need to override and restore it + rl_basic_quote_characters = ctypes.c_char_p.in_dll(readline_lib, "rl_basic_quote_characters") + orig_rl_basic_quote_characters_addr = ctypes.cast(rl_basic_quote_characters, ctypes.c_void_p).value + + # BrokenPipeError and FileNotFoundError exist only in Python 3. Use IOError for Python 2. if six.PY3: BROKEN_PIPE_ERROR = BrokenPipeError @@ -128,7 +190,7 @@ # The next 3 variables and associated setter functions effect how arguments are parsed for decorated commands -# which use one of the decorators such as @with_argument_list or @with_argparser +# which use one of the decorators such as @with_argument_list or @with_argparser # The defaults are sane and maximize ease of use for new applications based on cmd2. # To maximize backwards compatibility, we recommend setting USE_ARG_LIST to "False" @@ -141,6 +203,10 @@ # For @options commands, pass a list of argument strings instead of a single argument string to the do_* methods USE_ARG_LIST = True +# Used for tab completion and word breaks. Do not change. +QUOTES = ['"', "'"] +REDIRECTION_CHARS = ['|', '<', '>'] + def set_posix_shlex(val): """ Allows user of cmd2 to choose between POSIX and non-POSIX splitting of args for decorated commands. @@ -169,225 +235,6 @@ def set_use_arg_list(val): USE_ARG_LIST = val -# noinspection PyUnusedLocal -def basic_complete(text, line, begidx, endidx, match_against): - """ - Performs tab completion against a list - :param text: str - the string prefix we are attempting to match (all returned matches must begin with it) - :param line: str - the current input line with leading whitespace removed - :param begidx: int - the beginning index of the prefix text - :param endidx: int - the ending index of the prefix text - :param match_against: iterable - the list being matched against - :return: List[str] - a list of possible tab completions - """ - completions = [cur_str for cur_str in match_against if cur_str.startswith(text)] - - # If there is only 1 match and it's at the end of the line, then add a space - if len(completions) == 1 and endidx == len(line): - completions[0] += ' ' - - completions.sort() - return completions - - -def flag_based_complete(text, line, begidx, endidx, flag_dict, all_else=None): - """ - Tab completes based on a particular flag preceding the token being completed - :param text: str - the string prefix we are attempting to match (all returned matches must begin with it) - :param line: str - the current input line with leading whitespace removed - :param begidx: int - the beginning index of the prefix text - :param endidx: int - the ending index of the prefix text - :param flag_dict: dict - dictionary whose structure is the following: - keys - flags (ex: -c, --create) that result in tab 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: iterable or function - an optional parameter for tab completing any token that isn't preceded - by a flag in flag_dict - :return: List[str] - a list of possible tab completions - """ - - # Get all tokens prior to token being completed - try: - prev_space_index = max(line.rfind(' ', 0, begidx), 0) - tokens = shlex.split(line[:prev_space_index], posix=POSIX_SHLEX) - except ValueError: - # Invalid syntax for shlex (Probably due to missing closing quote) - return [] - - if len(tokens) == 0: - return [] - - completions = [] - match_against = all_else - - # Must have at least the command and one argument for a flag to be present - if len(tokens) > 1: - flag = tokens[-1] - if flag in flag_dict: - match_against = flag_dict[flag] - - # Perform tab completion using an iterable - if isinstance(match_against, collections.Iterable): - completions = [cur_str for cur_str in match_against if cur_str.startswith(text)] - - # If there is only 1 match and it's at the end of the line, then add a space - if len(completions) == 1 and endidx == len(line): - completions[0] += ' ' - - # Perform tab completion using a function - elif callable(match_against): - completions = match_against(text, line, begidx, endidx) - - completions.sort() - return completions - - -def index_based_complete(text, line, begidx, endidx, index_dict, all_else=None): - """ - Tab completes based on a fixed position in the input string - :param text: str - the string prefix we are attempting to match (all returned matches must begin with it) - :param line: str - the current input line with leading whitespace removed - :param begidx: int - the beginning index of the prefix text - :param endidx: int - the ending index of the prefix text - :param index_dict: dict - dictionary whose structure is the following: - 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: iterable or function - an optional parameter for tab completing any token that isn't at an - index in index_dict - :return: List[str] - a list of possible tab completions - """ - - # Get all tokens prior to token being completed - try: - prev_space_index = max(line.rfind(' ', 0, begidx), 0) - tokens = shlex.split(line[:prev_space_index], posix=POSIX_SHLEX) - except ValueError: - # Invalid syntax for shlex (Probably due to missing closing quote) - return [] - - if len(tokens) == 0: - return [] - - completions = [] - - # Get the index of the token being completed - index = len(tokens) - - # Check if token is at an index in the dictionary - if index in index_dict: - match_against = index_dict[index] - else: - match_against = all_else - - # Perform tab completion using an iterable - if isinstance(match_against, collections.Iterable): - completions = [cur_str for cur_str in match_against if cur_str.startswith(text)] - - # If there is only 1 match and it's at the end of the line, then add a space - if len(completions) == 1 and endidx == len(line): - completions[0] += ' ' - - # Perform tab completion using a function - elif callable(match_against): - completions = match_against(text, line, begidx, endidx) - - completions.sort() - return completions - - -def path_complete(text, line, begidx, endidx, dir_exe_only=False, dir_only=False): - """Method called to complete an input line by local file system path completion. - - :param text: str - the string prefix we are attempting to match (all returned matches must begin with it) - :param line: str - the current input line with leading whitespace removed - :param begidx: int - the beginning index of the prefix text - :param endidx: int - the ending index of the prefix text - :param dir_exe_only: bool - only return directories and executables, not non-executable files - :param dir_only: bool - only return directories - :return: List[str] - a list of possible tab completions - """ - - # Get all tokens prior to token being completed - try: - prev_space_index = max(line.rfind(' ', 0, begidx), 0) - tokens = shlex.split(line[:prev_space_index], posix=POSIX_SHLEX) - except ValueError: - # Invalid syntax for shlex (Probably due to missing closing quote) - return [] - - if len(tokens) == 0: - return [] - - # 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): - add_trailing_sep_if_dir = True - - # Readline places begidx after ~ and path separators (/) so we need to extract any directory - # path that appears before the search text - dirname = line[prev_space_index + 1:begidx] - - # If no directory path and no search text has been entered, then search in the CWD for * - if not dirname and not text: - search_str = os.path.join(os.getcwd(), '*') - else: - # Purposely don't match any path containing wildcards - what we are doing is complicated enough! - wildcards = ['*', '?'] - for wildcard in wildcards: - if wildcard in dirname or wildcard in text: - return [] - - if not dirname: - dirname = os.getcwd() - elif dirname == '~': - # If a ~ was used without a separator between text, then this is invalid - if text: - return [] - # If only a ~ was entered, then complete it with a slash - else: - return [os.path.sep] - - # Build the search string - search_str = os.path.join(dirname, text + '*') - - # Expand "~" to the real user directory - search_str = os.path.expanduser(search_str) - - # Find all matching path completions - path_completions = glob.glob(search_str) - - # If we only want directories and executables, filter everything else out first - if dir_exe_only: - path_completions = [c for c in path_completions if os.path.isdir(c) or os.access(c, os.X_OK)] - elif dir_only: - path_completions = [c for c in path_completions if os.path.isdir(c)] - - # Get the basename of the paths - completions = [] - for c in path_completions: - basename = os.path.basename(c) - - # Add a separator after directories if the next character isn't already a separator - if os.path.isdir(c) and add_trailing_sep_if_dir: - basename += os.path.sep - - completions.append(basename) - - # If there is a single completion - if len(completions) == 1: - # If it is a file and we are at the end of the line, then add a space - if os.path.isfile(path_completions[0]) and endidx == len(line): - completions[0] += ' ' - - completions.sort() - return completions - - class OptionParser(optparse.OptionParser): """Subclass of optparse.OptionParser which stores a reference to the do_* method it is parsing options for. @@ -574,8 +421,19 @@ def cmd_wrapper(instance, cmdline): # If there are subcommands, store their names in a list to support tab-completion of subcommand names if argparser._subparsers is not None: - subcommand_names = argparser._subparsers._group_actions[0]._name_parser_map.keys() - cmd_wrapper.__dict__['subcommand_names'] = subcommand_names + + # Key is subcommand name and value is completer function + subcommands = collections.OrderedDict() + + # Get all subcommands and check if they have completer functions + for name, parser in argparser._subparsers._group_actions[0]._name_parser_map.items(): + if 'completer' in parser._defaults: + completer = parser._defaults['completer'] + else: + completer = None + subcommands[name] = completer + + cmd_wrapper.__dict__['subcommands'] = subcommands return cmd_wrapper @@ -1127,9 +985,11 @@ def __init__(self, completekey='tab', stdin=None, stdout=None, persistent_histor # Call super class constructor. Need to do it in this way for Python 2 and 3 compatibility cmd.Cmd.__init__(self, completekey=completekey, stdin=stdin, stdout=stdout) - # Commands to exclude from the help menu or history command - self.exclude_from_help = ['do_eof', 'do_eos', 'do__relative_load'] - self.excludeFromHistory = '''history edit eof eos'''.split() + # Commands to exclude from the help menu and tab completion + self.hidden_commands = ['eof', 'eos', '_relative_load'] + + # Commands to exclude from the history command + self.exclude_from_history = '''history edit eof eos'''.split() self._finalize_app_parameters() @@ -1191,6 +1051,23 @@ def __init__(self, completekey='tab', stdin=None, stdout=None, persistent_histor if startup_script is not None: self.cmdqueue.append('load {}'.format(startup_script)) + ############################################################################################################ + # The following variables are used by tab-completion functions. They are reset each time complete() is run + # using set_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 + + # If the tab-completion matches should be displayed in a way that is different than the actual match values, + # then place those results in this list. path_complete uses this to show only the basename of completions. + self.display_matches = [] + # ----- Methods related to presenting output to the user ----- @property @@ -1283,6 +1160,7 @@ def ppaged(self, msg, end='\n'): # Attempt to detect if we are not running within a fully functional terminal. # Don't try to use the pager when being run by a continuous integration system like Jenkins + pexpect. functional_terminal = False + if self.stdin.isatty() and self.stdout.isatty(): if sys.platform.startswith('win') or os.environ.get('TERM') is not None: functional_terminal = True @@ -1290,6 +1168,7 @@ def ppaged(self, msg, end='\n'): # Don't attempt to use a pager that can block if redirecting or running a script (either text or Python) # Also only attempt to use a pager if actually running in a real fully functional terminal if functional_terminal and not self.redirecting and not self._in_py and not self._script_dir: + if sys.platform.startswith('win'): pager_cmd = 'more' else: @@ -1335,24 +1214,10 @@ def colorize(self, val, color): return self._colorcodes[color][True] + val + self._colorcodes[color][False] return val - # ----- Methods which override stuff in cmd ----- - - # noinspection PyMethodOverriding - def completenames(self, text, line, begidx, endidx): - """Override of cmd method which completes command names both for command completion and help.""" - # Call super class method. Need to do it this way for Python 2 and 3 compatibility - cmd_completion = cmd.Cmd.completenames(self, text) - - # If we are completing the initial command name and get exactly 1 result and are at end of line, add a space - if begidx == 0 and len(cmd_completion) == 1 and endidx == len(line): - cmd_completion[0] += ' ' - - return cmd_completion - def get_subcommands(self, command): """ - Returns a list of a command's subcommands if they exist - :param command: + Returns a list of a command's subcommand names if they exist + :param command: the command we are querying :return: A subcommand list or None """ @@ -1364,10 +1229,757 @@ def get_subcommands(self, command): if funcname: # Check to see if this function was decorated with an argparse ArgumentParser func = getattr(self, funcname) - subcommand_names = func.__dict__.get('subcommand_names', None) + subcommands = func.__dict__.get('subcommands', None) + if subcommands is not None: + subcommand_names = subcommands.keys() return subcommand_names + def get_subcommand_completer(self, command, subcommand): + """ + Returns a subcommand's tab completion function if one exists + :param command: command which owns the subcommand + :param subcommand: the subcommand we are querying + :return: A completer or None + """ + + completer = None + + # Check if is a valid command + funcname = self._func_named(command) + + if funcname: + # Check to see if this function was decorated with an argparse ArgumentParser + func = getattr(self, funcname) + subcommands = func.__dict__.get('subcommands', None) + if subcommands is not None: + completer = subcommands[subcommand] + + return completer + + # ----- Methods related to tab completion ----- + + def set_completion_defaults(self): + """ + Resets tab completion settings + Needs to be called each time readline runs tab completion + """ + self.allow_appended_space = True + self.allow_closing_quote = True + self.display_matches = [] + + @staticmethod + def display_match_list_gnu_readline(substitution, matches, longest_match_length): + """ + Prints a match list using GNU readline's rl_display_match_list() + :param substitution: str - the substitution written to the command line + :param matches: list[str] - the tab completion matches to display + :param longest_match_length: int - longest printed length of the matches + """ + if rl_type == RlType.GNU: + # We will use readline's display function (rl_display_match_list()), so we + # need to encode our string as bytes to place in a C array. + if six.PY3: + encoded_substitution = bytes(substitution, encoding='utf-8') + encoded_matches = [bytes(cur_match, encoding='utf-8') for cur_match in matches] + else: + encoded_substitution = bytes(substitution) + encoded_matches = [bytes(cur_match) for cur_match in matches] + + # rl_display_match_list() expects matches to be in argv format where + # substitution is the first element, followed by the matches, and then a NULL. + # noinspection PyCallingNonCallable,PyTypeChecker + strings_array = (ctypes.c_char_p * (1 + len(encoded_matches) + 1))() + + # Copy in the encoded strings and add a NULL to the end + strings_array[0] = encoded_substitution + strings_array[1:-1] = encoded_matches + strings_array[-1] = None + + # Call readline's display function + # rl_display_match_list(strings_array, number of completion matches, longest match length) + readline_lib.rl_display_match_list(strings_array, len(encoded_matches), longest_match_length) + + # rl_forced_update_display() is the proper way to redraw the prompt and line, but we + # have to use ctypes to do it since Python's readline API does not wrap the function + readline_lib.rl_forced_update_display() + + # Since we updated the display, readline asks that rl_display_fixed be set for efficiency + display_fixed = ctypes.c_int.in_dll(readline_lib, "rl_display_fixed") + display_fixed.value = 1 + + @staticmethod + def display_match_list_pyreadline(matches): + """ + Prints a match list using pyreadline's _display_completions() + :param matches: list[str] - the tab completion matches to display + """ + if rl_type == RlType.PYREADLINE: + orig_pyreadline_display(matches) + + def tokens_for_completion(self, line, begidx, endidx): + """ + Used by tab completion functions to get all tokens through the one being completed + :param line: str - the current input line with leading whitespace removed + :param begidx: int - the beginning index of the prefix text + :param endidx: int - 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 + raw_tokens: list of tokens as they appear on the command line, meaning their quotes are 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 completed + + On Failure + Both items are None + """ + unclosed_quote = '' + quotes_to_try = copy.copy(QUOTES) + + tmp_line = line[:endidx] + tmp_endidx = endidx + + # Parse the line into tokens + while True: + try: + # Use non-POSIX parsing to keep the quotes around the tokens + initial_tokens = shlex.split(tmp_line[:tmp_endidx], posix=False) + + # If the cursor is at an empty token outside of a quoted string, + # then that is the token being completed. Add it to the list. + if not unclosed_quote and begidx == tmp_endidx: + initial_tokens.append('') + break + except ValueError: + # ValueError can be caused by missing closing quote + if len(quotes_to_try) == 0: + # Since we have no more quotes to try, something else + # is causing the parsing error. Return None since + # this means the line is malformed. + return None, None + + # Add a closing quote and try to parse again + unclosed_quote = quotes_to_try[0] + quotes_to_try = quotes_to_try[1:] + + tmp_line = line[:endidx] + tmp_line += unclosed_quote + tmp_endidx = endidx + 1 + + if self.allow_redirection: + + # Since redirection is enabled, we need to treat redirection characters (|, <, >) + # as word breaks when they are in unquoted strings. Go through each token + # and further split them on these characters. Each run of redirect characters + # is treated as a single token. + raw_tokens = [] + + for cur_initial_token in initial_tokens: + + # Save tokens up to 1 character in length or quoted tokens. No need to parse these. + if len(cur_initial_token) <= 1 or cur_initial_token[0] in QUOTES: + raw_tokens.append(cur_initial_token) + continue + + # Keep track of the current token we are building + cur_raw_token = '' + + # Iterate over each character in this token + cur_index = 0 + cur_char = cur_initial_token[cur_index] + + while True: + if cur_char not in REDIRECTION_CHARS: + + # Keep appending to cur_raw_token until we hit a redirect char + while cur_char not in REDIRECTION_CHARS: + cur_raw_token += cur_char + cur_index += 1 + if cur_index < len(cur_initial_token): + cur_char = cur_initial_token[cur_index] + else: + break + + else: + redirect_char = cur_char + + # Keep appending to cur_raw_token until we hit something other than redirect_char + while cur_char == redirect_char: + cur_raw_token += cur_char + cur_index += 1 + if cur_index < len(cur_initial_token): + cur_char = cur_initial_token[cur_index] + else: + break + + # Save the current token + raw_tokens.append(cur_raw_token) + cur_raw_token = '' + + # Check if we've viewed all characters + if cur_index >= len(cur_initial_token): + break + else: + raw_tokens = initial_tokens + + # Save the unquoted tokens + tokens = [strip_quotes(cur_token) for cur_token in raw_tokens] + + # If the token being completed had an unclosed quote, we need + # to remove the closing quote that was added in order for it + # to match what was on the command line. + if unclosed_quote: + raw_tokens[-1] = raw_tokens[-1][:-1] + + return tokens, raw_tokens + + # noinspection PyUnusedLocal + @staticmethod + def basic_complete(text, line, begidx, endidx, match_against): + """ + Performs tab completion against a list + + :param text: str - the string prefix we are attempting to match (all returned matches must begin with it) + :param line: str - the current input line with leading whitespace removed + :param begidx: int - the beginning index of the prefix text + :param endidx: int - the ending index of the prefix text + :param match_against: Collection - the list being matched against + :return: List[str] - a sorted list of possible tab completions + """ + # Make sure we were given a Collection with items to match against + if not isinstance(match_against, Collection) or len(match_against) == 0: + return [] + + # Perform matching and eliminate duplicates + matches = [cur_match for cur_match in set(match_against) if cur_match.startswith(text)] + + matches.sort() + return matches + + def delimiter_complete(self, text, line, begidx, endidx, match_against, delimiter): + """ + Performs tab completion against a list but each match is split on a delimiter and only + the portion of the match being tab 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 + as suggestions: + + /home/user/file.txt /home/user/program.c + /home/user/maps/ /home/user/cmd2.py + + Instead you are shown: + + file.txt program.c + maps/ cmd2.py + + For a large set of data, this can be visually more pleasing and easier to search. + + Another example would be strings formatted with the following syntax: company::department::name + In this case the delimiter would be :: and the user could easily narrow down what they are looking + for if they were only shown suggestions in the category they are at in the string. + + :param text: str - the string prefix we are attempting to match (all returned matches must begin with it) + :param line: str - the current input line with leading whitespace removed + :param begidx: int - the beginning index of the prefix text + :param endidx: int - the ending index of the prefix text + :param match_against: Collection - the list being matched against + :param delimiter: str - what delimits each portion of the matches (ex: paths are delimited by a slash) + :return: List[str] - a sorted list of possible tab completions + """ + matches = self.basic_complete(text, line, begidx, endidx, match_against) + + # Display only the portion of the match that's being completed based on delimiter + if len(matches) > 0: + + # Get the common beginning for the matches + common_prefix = os.path.commonprefix(matches) + prefix_tokens = common_prefix.split(delimiter) + + # Calculate what portion of the match we are completing + display_token_index = 0 + if len(prefix_tokens) > 0: + display_token_index = len(prefix_tokens) - 1 + + # Get this portion for each match and store them in self.display_matches + for cur_match in matches: + match_tokens = cur_match.split(delimiter) + display_token = match_tokens[display_token_index] + + if len(display_token) == 0: + display_token = delimiter + self.display_matches.append(display_token) + + return matches + + def flag_based_complete(self, text, line, begidx, endidx, flag_dict, all_else=None): + """ + Tab completes based on a particular flag preceding the token being completed + :param text: str - the string prefix we are attempting to match (all returned matches must begin with it) + :param line: str - the current input line with leading whitespace removed + :param begidx: int - the beginning index of the prefix text + :param endidx: int - the ending index of the prefix text + :param flag_dict: dict - dictionary whose structure is the following: + keys - flags (ex: -c, --create) that result in tab 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: Collection or function - an optional parameter for tab completing any token that isn't preceded + by a flag in flag_dict + :return: List[str] - a sorted list of possible tab completions + """ + # Get all tokens through the one being completed + tokens, _ = self.tokens_for_completion(line, begidx, endidx) + if tokens is None: + return [] + + completions_matches = [] + match_against = all_else + + # Must have at least 2 args for a flag to precede the token being completed + if len(tokens) > 1: + flag = tokens[-2] + if flag in flag_dict: + match_against = flag_dict[flag] + + # Perform tab completion using an Collection. These matches are already sorted. + if isinstance(match_against, Collection): + completions_matches = 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) + completions_matches.sort() + + return completions_matches + + def index_based_complete(self, text, line, begidx, endidx, index_dict, all_else=None): + """ + Tab completes based on a fixed position in the input string + :param text: str - the string prefix we are attempting to match (all returned matches must begin with it) + :param line: str - the current input line with leading whitespace removed + :param begidx: int - the beginning index of the prefix text + :param endidx: int - the ending index of the prefix text + :param index_dict: dict - dictionary whose structure is the following: + 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: Collection or function - an optional parameter for tab completing any token that isn't at an + index in index_dict + :return: List[str] - a sorted list of possible tab completions + """ + # Get all tokens through the one being completed + tokens, _ = self.tokens_for_completion(line, begidx, endidx) + if tokens is None: + return [] + + matches = [] + + # Get the index of the token being completed + index = len(tokens) - 1 + + # Check if token is at an index in the dictionary + if index in index_dict: + match_against = index_dict[index] + else: + match_against = all_else + + # Perform tab completion using an Collection. These matches are already sorted. + if isinstance(match_against, Collection): + matches = self.basic_complete(text, line, begidx, endidx, match_against) + + # Perform tab completion using a function + elif callable(match_against): + matches = match_against(text, line, begidx, endidx) + matches.sort() + + return matches + + # noinspection PyUnusedLocal + def path_complete(self, text, line, begidx, endidx, dir_exe_only=False, dir_only=False): + """Performs completion of local file system paths + + :param text: str - the string prefix we are attempting to match (all returned matches must begin with it) + :param line: str - the current input line with leading whitespace removed + :param begidx: int - the beginning index of the prefix text + :param endidx: int - the ending index of the prefix text + :param dir_exe_only: bool - only return directories and executables, not non-executable files + :param dir_only: bool - only return directories + :return: List[str] - a sorted list of possible tab completions + """ + # 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): + add_trailing_sep_if_dir = True + + # Used to replace cwd in the final results + cwd = os.getcwd() + cwd_added = False + + # Used to replace ~ in the final results + user_path = os.path.expanduser('~') + tilde_expanded = False + + # If the search text is blank, then search in the CWD for * + if not text: + search_str = os.path.join(os.getcwd(), '*') + cwd_added = True + else: + # Purposely don't match any path containing wildcards - what we are doing is complicated enough! + wildcards = ['*', '?'] + for wildcard in wildcards: + if wildcard in text: + return [] + + # Used if we need to prepend a directory to the search string + dirname = '' + + # If the user only entered a '~', then complete it with a slash + if text == '~': + # This is a directory, so don't add a space or quote + self.allow_appended_space = False + self.allow_closing_quote = False + return [text + os.path.sep] + + elif text.startswith('~'): + # Tilde without separator between path is invalid + if not text.startswith('~' + os.path.sep): + return [] + + # Mark that we are expanding a tilde + tilde_expanded = True + + # If the search text does not have a directory, then use the cwd + elif not os.path.dirname(text): + dirname = os.getcwd() + cwd_added = True + + # Build the search string + search_str = os.path.join(dirname, text + '*') + + # Expand "~" to the real user directory + search_str = os.path.expanduser(search_str) + + # Find all matching path completions + matches = glob.glob(search_str) + + # Filter based on type + if dir_exe_only: + matches = [c for c in matches if os.path.isdir(c) or os.access(c, os.X_OK)] + elif dir_only: + matches = [c for c in matches if os.path.isdir(c)] + + # Don't append a space or closing quote to directory + if len(matches) == 1 and not os.path.isfile(matches[0]): + self.allow_appended_space = False + self.allow_closing_quote = False + + # 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 + if cwd_added: + matches = [cur_path.replace(cwd + os.path.sep, '', 1) for cur_path in matches] + + # Restore a tilde if we expanded one + if tilde_expanded: + matches = [cur_path.replace(user_path, '~', 1) for cur_path in matches] + + matches.sort() + return matches + + @staticmethod + def get_exes_in_path(starts_with): + """ + Returns names of executables in a user's path + :param starts_with: str - what the exes should start with. leave blank for all exes in path. + :return: List[str] - a sorted list of matching exe names + """ + # Purposely don't match any executable containing wildcards + wildcards = ['*', '?'] + for wildcard in wildcards: + if wildcard in starts_with: + return [] + + # Get a list of every directory in the PATH environment variable and ignore symbolic links + paths = [p for p in os.getenv('PATH').split(os.path.pathsep) if not os.path.islink(p)] + + # Use a set to store exe names since there can be duplicates + exes_set = set() + + # Find every executable file in the user's path that matches the pattern + for path in paths: + full_path = os.path.join(path, starts_with) + matches = [f for f in glob.glob(full_path + '*') if os.path.isfile(f) and os.access(f, os.X_OK)] + + for match in matches: + exes_set.add(os.path.basename(match)) + + exes_list = list(exes_set) + exes_list.sort() + return exes_list + + def shell_cmd_complete(self, text, line, begidx, endidx, complete_blank=False): + """Performs completion of executables either in a user's path or a given path + :param text: str - the string prefix we are attempting to match (all returned matches must begin with it) + :param line: str - the current input line with leading whitespace removed + :param begidx: int - the beginning index of the prefix text + :param endidx: int - the ending index of the prefix text + :param complete_blank: bool - 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: List[str] - a sorted list of possible tab completions + """ + # Don't tab complete anything if no shell command has been started + if not complete_blank and len(text) == 0: + return [] + + # If there are no path characters in the search text, then do shell command completion in the user's path + if os.path.sep not in text: + return self.get_exes_in_path(text) + + # Otherwise look for executables in the given path + else: + return self.path_complete(text, line, begidx, endidx, dir_exe_only=True) + + def _redirect_complete(self, text, line, begidx, endidx, compfunc): + """ + Called by complete() as the first tab completion function for all commands + It determines if it should tab complete for redirection (|, <, >, >>) or use the + completer function for the current command + + :param text: str - the string prefix we are attempting to match (all returned matches must begin with it) + :param line: str - the current input line with leading whitespace removed + :param begidx: int - the beginning index of the prefix text + :param endidx: int - the ending index of the prefix text + :param compfunc: Callable - the completer function for the current command + this will be called if we aren't completing for redirection + :return: List[str] - a sorted list of possible tab completions + """ + if self.allow_redirection: + + # 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 raw_tokens is None: + return [] + + if len(raw_tokens) > 1: + + # Build a list of all redirection tokens + all_redirects = REDIRECTION_CHARS + ['>>'] + + # Check if there are redirection strings prior to the token being completed + seen_pipe = False + has_redirection = False + + for cur_token in raw_tokens[:-1]: + if cur_token in all_redirects: + has_redirection = True + + if cur_token == '|': + seen_pipe = True + + # Get token prior to the one being completed + prior_token = raw_tokens[-2] + + # If a pipe is right before the token being completed, complete a shell command as the piped process + if prior_token == '|': + return self.shell_cmd_complete(text, line, begidx, endidx) + + # Otherwise do path completion either as files to redirectors or arguments to the piped process + elif prior_token in all_redirects or seen_pipe: + 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 + elif has_redirection: + return [] + + # Call the command's completer function + return compfunc(text, line, begidx, endidx) + + def _display_matches_gnu_readline(self, substitution, matches, longest_match_length): + """ + cmd2's default GNU readline function that prints tab-completion matches to the screen + This exists to allow the printing of self.display_matches if it has data. Otherwise matches prints. + The actual printing is done by display_match_list_gnu_readline(). + + If you need a custom match display function for a particular completion type, then set it by calling + readline.set_completion_display_matches_hook() during the completer routine. + Your custom display function should ultimately call display_match_list_gnu_readline() to print. + + :param substitution: str - the substitution written to the command line + :param matches: list[str] - the tab completion matches to display + :param longest_match_length: int - longest printed length of the matches + """ + if len(self.display_matches) > 0: + matches_to_display = self.display_matches + else: + matches_to_display = matches + + # Eliminate duplicates and sort + matches_to_display_set = set(matches_to_display) + matches_to_display = list(matches_to_display_set) + matches_to_display.sort() + + # Display the matches + self.display_match_list_gnu_readline(substitution, matches_to_display, longest_match_length) + + def _display_matches_pyreadline(self, matches): + """ + cmd2's default pyreadline function that prints tab-completion matches to the screen + This exists to allow the printing of self.display_matches if it has data. Otherwise matches prints. + The actual printing is done by display_match_list_pyreadline(). + + If you need a custom match display function for a particular completion type, then set + readline.rl.mode._display_completions to that function during the completer routine. + Your custom display function should ultimately call display_match_list_pyreadline() to print. + + :param matches: list[str] - the tab completion matches to display + """ + if len(self.display_matches) > 0: + matches_to_display = self.display_matches + else: + matches_to_display = matches + + # Eliminate duplicates and sort + matches_to_display_set = set(matches_to_display) + matches_to_display = list(matches_to_display_set) + matches_to_display.sort() + + # Display the matches + self.display_match_list_pyreadline(matches_to_display) + + def _handle_completion_token_quote(self, raw_completion_token): + """ + This is called by complete() to add an opening quote to the token being completed if it is needed + The readline input buffer is then updated with the new string + :param raw_completion_token: str - the token being completed as it appears on the command line + :return: True if a quote was added, False otherwise + """ + if len(self.completion_matches) == 0: + return False + + quote_added = False + + # Check if token on screen is already quoted + if len(raw_completion_token) == 0 or raw_completion_token[0] not in QUOTES: + + # Get the common prefix of all matches. This is what be written to the screen. + common_prefix = os.path.commonprefix(self.completion_matches) + + # If common_prefix contains a space, then we must add an opening quote to it + if ' ' in common_prefix: + + # Figure out what kind of quote to add + if '"' in common_prefix: + quote = "'" + else: + quote = '"' + + new_completion_token = quote + common_prefix + + # Handle a single result + if len(self.completion_matches) == 1: + str_to_append = '' + + # Add a closing quote if allowed + if self.allow_closing_quote: + str_to_append += quote + + orig_line = readline.get_line_buffer() + endidx = readline.get_endidx() + + # If we are at the end of the line, then add a space if allowed + if self.allow_appended_space and endidx == len(orig_line): + str_to_append += ' ' + + new_completion_token += str_to_append + + # Update the line + quote_added = True + self._replace_completion_token(raw_completion_token, new_completion_token) + + return quote_added + + def _replace_completion_token(self, raw_completion_token, new_completion_token): + """ + Replaces the token being completed in the readline line buffer which updates the screen + This is used for things like adding an opening quote for completions with spaces + :param raw_completion_token: str - the original token being completed as it appears on the command line + :param new_completion_token: str- the replacement token + :return: None + """ + orig_line = readline.get_line_buffer() + endidx = readline.get_endidx() + + starting_index = orig_line[:endidx].rfind(raw_completion_token) + + if starting_index != -1: + # Build the new line + new_line = orig_line[:starting_index] + new_line += new_completion_token + new_line += orig_line[endidx:] + + # Calculate the new cursor offset + len_diff = len(new_completion_token) - len(raw_completion_token) + new_point = endidx + len_diff + + # Replace the line and update the cursor offset + self._set_readline_line(new_line) + self._set_readline_point(new_point) + + @staticmethod + def _set_readline_line(new_line): + """ + Sets the readline line buffer + :param new_line: str - the new line contents + """ + if rl_type == RlType.GNU: + # Byte encode the new line + if six.PY3: + encoded_line = bytes(new_line, encoding='utf-8') + else: + encoded_line = bytes(new_line) + + # Replace the line + readline_lib.rl_replace_line(encoded_line, 0) + + elif rl_type == RlType.PYREADLINE: + readline.rl.mode.l_buffer.set_line(new_line) + + @staticmethod + def _set_readline_point(new_point): + """ + Sets the cursor offset in the readline line buffer + :param new_point: int - the new cursor offset + """ + if rl_type == RlType.GNU: + rl_point = ctypes.c_int.in_dll(readline_lib, "rl_point") + rl_point.value = new_point + + elif rl_type == RlType.PYREADLINE: + readline.rl.mode.l_buffer.point = new_point + + # ----- Methods which override stuff in cmd ----- + def complete(self, text, state): """Override of command method which returns the next possible completion for 'text'. @@ -1383,22 +1995,45 @@ def complete(self, text, state): :param state: int - non-negative integer """ if state == 0: - origline = readline.get_line_buffer() - line = origline.lstrip() - stripped = len(origline) - len(line) - begidx = readline.get_begidx() - stripped - endidx = readline.get_endidx() - stripped - - # If begidx is greater than 0, then the cursor is past the command + unclosed_quote = '' + self.set_completion_defaults() + + # lstrip the original line + orig_line = readline.get_line_buffer() + line = orig_line.lstrip() + 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(readline.get_begidx() - stripped, 0) + endidx = max(readline.get_endidx() - stripped, 0) + + # We only break words on whitespace and quotes when tab completing. + # Therefore shortcuts become part of the text variable if there isn't 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 line. + shortcut_to_restore = '' + if begidx == 0: + for (shortcut, expansion) in self.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 + + # If begidx is greater than 0, then we are no longer completing the command if begidx > 0: # Parse the command line command, args, expanded_line = self.parseline(line) # We overwrote line with a properly formatted but fully stripped version - # Restore the end spaces from the original since line is only supposed to be - # lstripped when passed to completer functions according to Python docs - rstripped_len = len(origline) - len(origline.rstrip()) + # Restore the end spaces since line is only supposed to be lstripped when + # passed to completer functions according to Python docs + rstripped_len = len(line) - len(line.rstrip()) expanded_line += ' ' * rstripped_len # Fix the index values if expanded_line has a different size than line @@ -1410,90 +2045,202 @@ def complete(self, text, state): # Overwrite line to pass into completers line = expanded_line - if command == '': - compfunc = self.completedefault - else: + # Get all tokens through the one being completed + tokens, raw_tokens = self.tokens_for_completion(line, begidx, endidx) + + # Either had a parsing error or are trying to complete the command token + # The latter can happen if default_to_shell is True and parseline() allowed + # assumed something like " or ' was a command. + if tokens is None or len(tokens) == 1: + self.completion_matches = [] + return None + + # readline still performs word breaks in quotes. Therefore quoted search text with + # a space would have resulted in begidx pointing to the middle of the token we want + # to complete. Figure out where that token actually begins. + actual_begidx = line[:endidx].rfind(tokens[-1]) + + # If actual_begidx is different than what readline gave us, save the beginning portion + # of the completion token that does not belong in text. We will remove it from the + # completions later since readline expects our completions to start with the original text. + text_to_remove = '' + + if actual_begidx != begidx: + text_to_remove = line[actual_begidx:begidx] + + # Adjust text and where it begins so the completer routines + # get unbroken search text to complete on. + text = text_to_remove + text + begidx = actual_begidx + + # Get the tokens with preserved quotes + raw_completion_token = raw_tokens[-1] - # Get the completion function for this command + # Check if a valid command was entered + if command in self.get_all_commands(): + # Get the completer function for this command try: compfunc = getattr(self, 'complete_' + command) except AttributeError: - if self.default_to_shell and command in self._get_exes_in_path(command): - compfunc = functools.partial(path_complete) - else: - compfunc = self.completedefault + compfunc = self.completedefault - # If there are subcommands, then try completing those if the cursor is in - # the token at index 1, otherwise default to using compfunc subcommands = self.get_subcommands(command) if subcommands is not None: + # Since there are subcommands, then try completing those if the cursor is in + # the token at index 1, otherwise default to using compfunc index_dict = {1: subcommands} - compfunc = functools.partial(index_based_complete, + compfunc = functools.partial(self.index_based_complete, index_dict=index_dict, all_else=compfunc) - # Call the completer function - self.completion_matches = compfunc(text, line, begidx, endidx) + # A valid command was not entered + else: + # Check if this command should be run as a shell command + if self.default_to_shell and command in self.get_exes_in_path(command): + compfunc = self.path_complete + else: + compfunc = self.completedefault + + # Attempt tab 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, compfunc) + + if len(self.completion_matches) > 0: + + # Add an opening quote if needed + if self._handle_completion_token_quote(raw_completion_token): + # An opening quote was added and the screen was updated. Return no results + self.completion_matches = [] + return None + + if text_to_remove or shortcut_to_restore: + # If self.display_matches is empty, then 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 readline did. + if len(self.display_matches) == 0: + self.display_matches = self.completion_matches + + # Check if we need to remove text from the beginning of tab completions + if text_to_remove: + self.completion_matches = \ + [m.replace(text_to_remove, '', 1) for m in self.completion_matches] + + # Check if we need to restore a shortcut in the tab completions + # so it doesn't get erased from the command line + if shortcut_to_restore: + self.completion_matches = \ + [shortcut_to_restore + match for match in self.completion_matches] + + # Check if the token being completed has an unclosed quote + if len(raw_completion_token) == 1: + first_char = raw_completion_token[0] + if first_char in QUOTES: + unclosed_quote = first_char + + elif len(raw_completion_token) > 1: + first_char = raw_completion_token[0] + last_char = raw_completion_token[-1] + if first_char in QUOTES and first_char != last_char: + unclosed_quote = first_char else: - # Complete the command against aliases and command names - strs_to_match = list(self.aliases.keys()) + # Complete token against aliases and command names + alias_names = set(self.aliases.keys()) + visible_commands = set(self.get_visible_commands()) + strs_to_match = list(alias_names | visible_commands) + self.completion_matches = self.basic_complete(text, line, begidx, endidx, strs_to_match) + + # Eliminate duplicates and sort + matches_set = set(self.completion_matches) + self.completion_matches = list(matches_set) + self.completion_matches.sort() - # Add command names - strs_to_match.extend(self.get_command_names()) + # Handle single result + if len(self.completion_matches) == 1: + str_to_append = '' - # Perform matching - completions = [cur_str for cur_str in strs_to_match if cur_str.startswith(text)] + # Add a closing quote if needed and allowed + if self.allow_closing_quote and unclosed_quote: + str_to_append += unclosed_quote - # If there is only 1 match and it's at the end of the line, then add a space - if len(completions) == 1 and endidx == len(line): - completions[0] += ' ' + # If we are at the end of the line, then add a space if allowed + if self.allow_appended_space and endidx == len(line): + str_to_append += ' ' - self.completion_matches = completions + self.completion_matches[0] += str_to_append try: return self.completion_matches[state] except IndexError: return None - def get_command_names(self): - """ Returns a list of commands """ - return [cur_name[3:] for cur_name in self.get_names() if cur_name.startswith('do_')] + def get_all_commands(self): + """ + Returns a sorted list of all commands + Any duplicates have been removed as well + """ + commands = [cur_name[3:] for cur_name in set(self.get_names()) if cur_name.startswith('do_')] + commands.sort() + return commands + + def get_visible_commands(self): + """ + Returns a sorted list of commands that have not been hidden + Any duplicates have been removed as well + """ + # This list is already sorted and has no duplicates + commands = self.get_all_commands() + + # Remove the hidden commands + for name in self.hidden_commands: + if name in commands: + commands.remove(name) + + return commands + + def get_help_topics(self): + """ Returns a sorted list of help topics with all duplicates removed """ + return [name[5:] for name in set(self.get_names()) if name.startswith('help_')] def complete_help(self, text, line, begidx, endidx): """ - Override of parent class method to handle tab completing subcommands + Override of parent class method to handle tab completing subcommands and not showing hidden commands + Returns a sorted list of possible tab completions """ - # Get all tokens prior to token being completed - try: - prev_space_index = max(line.rfind(' ', 0, begidx), 0) - tokens = shlex.split(line[:prev_space_index], posix=POSIX_SHLEX) - except ValueError: - # Invalid syntax for shlex (Probably due to missing closing quote) + # The command is the token at index 1 in the command line + cmd_index = 1 + + # The subcommand is the token at index 2 in the command line + subcmd_index = 2 + + # Get all tokens through the one being completed + tokens, _ = self.tokens_for_completion(line, begidx, endidx) + if tokens is None: return [] - completions = [] + matches = [] - # If we have "help" and a completed command token, then attempt to match subcommands - if len(tokens) == 2: + # Get the index of the token being completed + index = len(tokens) - 1 - # Match subcommands if any exist - subcommands = self.get_subcommands(tokens[1]) - if subcommands is not None: - completions = [cur_sub for cur_sub in subcommands if cur_sub.startswith(text)] + # Check if we are completing a command or help topic + if index == cmd_index: - # Run normal help completion from the parent class - else: - completions = cmd.Cmd.complete_help(self, text, line, begidx, endidx) + # Complete token against topics and visible commands + topics = set(self.get_help_topics()) + visible_commands = set(self.get_visible_commands()) + strs_to_match = list(topics | visible_commands) + matches = self.basic_complete(text, line, begidx, endidx, strs_to_match) + + # Check if we are completing a subcommand + elif index == subcmd_index: - # If only 1 command has been matched and it's at the end of the line, - # then add a space if it has subcommands - if len(completions) == 1 and endidx == len(line) and self.get_subcommands(completions[0]) is not None: - completions[0] += ' ' + # Match subcommands if any exist + command = tokens[cmd_index] + matches = self.basic_complete(text, line, begidx, endidx, self.get_subcommands(command)) - completions.sort() - return completions + return matches # noinspection PyUnusedLocal def sigint_handler(self, signum, frame): @@ -1504,8 +2251,10 @@ def sigint_handler(self, signum, frame): :param signum: int - signal number :param frame """ + # Save copy of pipe_proc since it could theoretically change while this is running pipe_proc = self.pipe_proc + if pipe_proc is not None: pipe_proc.terminate() @@ -1513,7 +2262,8 @@ def sigint_handler(self, signum, frame): raise KeyboardInterrupt("Got a keyboard interrupt") def preloop(self): - """Hook method executed once when the cmdloop() method is called.""" + """"Hook method executed once when the cmdloop() method is called.""" + # Register a default SIGINT signal handler for Ctrl+C signal.signal(signal.SIGINT, self.sigint_handler) @@ -1839,7 +2589,7 @@ def onecmd(self, line): return self.default(statement) # Since we have a valid command store it in the history - if statement.parsed.command not in self.excludeFromHistory: + if statement.parsed.command not in self.exclude_from_history: self.history.append(statement.parsed.raw) try: @@ -1949,12 +2699,34 @@ def _cmdloop(self): # An almost perfect copy from Cmd; however, the pseudo_raw_input portion # has been split out so that it can be called separately if self.use_rawinput and self.completekey: + + # Set up readline for our tab completion needs + if rl_type == RlType.GNU: + readline.set_completion_display_matches_hook(self._display_matches_gnu_readline) + + # Set GNU readline's rl_basic_quote_characters to NULL so it won't automatically add a closing quote + # We don't need to worry about setting rl_completion_suppress_quote since we never declared + # rl_completer_quote_characters. + rl_basic_quote_characters.value = None + + elif rl_type == RlType.PYREADLINE: + readline.rl.mode._display_completions = self._display_matches_pyreadline + try: self.old_completer = readline.get_completer() self.old_delims = readline.get_completer_delims() readline.set_completer(self.complete) - # Don't treat "-" as a readline delimiter since it is commonly used in filesystem paths - readline.set_completer_delims(self.old_delims.replace('-', '')) + + # Break words on whitespace and quotes when tab completing + completer_delims = " \t\n" + ''.join(QUOTES) + + if self.allow_redirection: + # If redirection is allowed, then break words on those characters too + completer_delims += ''.join(REDIRECTION_CHARS) + + readline.set_completer_delims(completer_delims) + + # Enable tab completion readline.parse_and_bind(self.completekey + ": complete") except NameError: pass @@ -1982,12 +2754,21 @@ def _cmdloop(self): stop = self.onecmd_plus_hooks(line) finally: if self.use_rawinput and self.completekey: + + # Restore what we changed in readline try: readline.set_completer(self.old_completer) readline.set_completer_delims(self.old_delims) except NameError: pass + if rl_type == RlType.GNU: + readline.set_completion_display_matches_hook(None) + rl_basic_quote_characters.value = orig_rl_basic_quote_characters_addr + + elif rl_type == RlType.PYREADLINE: + readline.rl.mode._display_completions = orig_pyreadline_display + # Need to set empty list this way because Python 2 doesn't support the clear() method on lists self.cmdqueue = [] self._script_dir = [] @@ -2004,7 +2785,8 @@ def do_alias(self, arglist): value - what the alias will be resolved to this can contain spaces and does not need to be quoted - Without arguments, `alias' prints a list of all aliases in a resuable form + Without arguments, 'alias' prints a list of all aliases in a reusable form which + can be outputted to a startup_script to preserve aliases across sessions. Example: alias ls !ls -lF """ @@ -2043,9 +2825,9 @@ def complete_alias(self, text, line, begidx, endidx): index_dict = \ { 1: self.aliases, - 2: self.get_command_names() + 2: self.get_visible_commands() } - return index_based_complete(text, line, begidx, endidx, index_dict, path_complete) + return self.index_based_complete(text, line, begidx, endidx, index_dict, self.path_complete) @with_argument_list def do_unalias(self, arglist): @@ -2078,7 +2860,7 @@ def do_unalias(self, arglist): def complete_unalias(self, text, line, begidx, endidx): """ Tab completion for unalias """ - return basic_complete(text, line, begidx, endidx, self.aliases) + return self.basic_complete(text, line, begidx, endidx, self.aliases) @with_argument_list def do_help(self, arglist): @@ -2101,6 +2883,9 @@ def do_help(self, arglist): else: # No special behavior needed, delegate to cmd base class do_help() cmd.Cmd.do_help(self, funcname[3:]) + else: + # This could be a help topic + cmd.Cmd.do_help(self, arglist[0]) else: # Show a menu of what commands help can be gotten for self._help_menu() @@ -2108,39 +2893,27 @@ def do_help(self, arglist): def _help_menu(self): """Show a list of commands which help can be displayed for. """ - # Get a list of all method names - names = self.get_names() - - # Remove any command names which are explicitly excluded from the help menu - for name in self.exclude_from_help: - if name in names: - names.remove(name) + # Get a sorted list of help topics + help_topics = self.get_help_topics() cmds_doc = [] cmds_undoc = [] - help_dict = {} - for name in names: - if name[:5] == 'help_': - help_dict[name[5:]] = 1 - names.sort() - # There can be duplicates if routines overridden - prevname = '' - for name in names: - if name[:3] == 'do_': - if name == prevname: - continue - prevname = name - command = name[3:] - if command in help_dict: - cmds_doc.append(command) - del help_dict[command] - elif getattr(self, name).__doc__: - cmds_doc.append(command) - else: - cmds_undoc.append(command) + + # Get a sorted list of visible command names + visible_commands = self.get_visible_commands() + + for command in visible_commands: + if command in help_topics: + cmds_doc.append(command) + help_topics.remove(command) + elif getattr(self, self._func_named(command)).__doc__: + cmds_doc.append(command) + else: + cmds_undoc.append(command) + self.poutput("%s\n" % str(self.doc_leader)) self.print_topics(self.doc_header, cmds_doc, 15, 80) - self.print_topics(self.misc_header, list(help_dict.keys()), 15, 80) + self.print_topics(self.misc_header, help_topics, 15, 80) self.print_topics(self.undoc_header, cmds_undoc, 15, 80) def do_shortcuts(self, _): @@ -2285,41 +3058,31 @@ def do_shell(self, command): """Execute a command as if at the OS prompt. Usage: shell [arguments]""" - proc = subprocess.Popen(command, stdout=self.stdout, shell=True) - proc.communicate() - - @staticmethod - def _get_exes_in_path(starts_with): - """ - Returns names of executables in a user's path - :param starts_with: str - what the exes should start with. leave blank for all exes in path. - :return: List[str] - a list of matching exe names - """ - - # Purposely don't match any executable containing wildcards - wildcards = ['*', '?'] - for wildcard in wildcards: - if wildcard in starts_with: - return [] - # Get a list of every directory in the PATH environment variable and ignore symbolic links - paths = [p for p in os.getenv('PATH').split(os.path.pathsep) if not os.path.islink(p)] + try: + tokens = shlex.split(command, posix=POSIX_SHLEX) + except ValueError as err: + self.perror(err, traceback_war=False) + return - # Use a set to store exe names since there can be duplicates - exes = set() + for index, _ in enumerate(tokens): + if len(tokens[index]) > 0: + # Check if the token is quoted. Since shlex.split() passed, there isn't + # an unclosed quote, so we only need to check the first character. + first_char = tokens[index][0] + if first_char in QUOTES: + tokens[index] = strip_quotes(tokens[index]) - # Find every executable file in the user's path that matches the pattern - for path in paths: - full_path = os.path.join(path, starts_with) - matches = [f for f in glob.glob(full_path + '*') if os.path.isfile(f) and os.access(f, os.X_OK)] + tokens[index] = os.path.expandvars(tokens[index]) + tokens[index] = os.path.expanduser(tokens[index]) - for match in matches: - exes.add(os.path.basename(match)) + # Restore the quotes + if first_char in QUOTES: + tokens[index] = first_char + tokens[index] + first_char - # Sort the exes alphabetically - results = list(exes) - results.sort() - return results + expanded_command = ' '.join(tokens) + proc = subprocess.Popen(expanded_command, stdout=self.stdout, shell=True) + proc.communicate() def complete_shell(self, text, line, begidx, endidx): """Handles tab completion of executable commands and local file system paths for the shell command @@ -2328,103 +3091,65 @@ def complete_shell(self, text, line, begidx, endidx): :param line: str - the current input line with leading whitespace removed :param begidx: int - the beginning index of the prefix text :param endidx: int - the ending index of the prefix text - :return: List[str] - a list of possible tab completions + :return: List[str] - a sorted list of possible tab completions """ + index_dict = {1: self.shell_cmd_complete} + return self.index_based_complete(text, line, begidx, endidx, index_dict, self.path_complete) - # Get all tokens prior to token being completed - try: - prev_space_index = max(line.rfind(' ', 0, begidx), 0) - tokens = shlex.split(line[:prev_space_index], posix=POSIX_SHLEX) - except ValueError: - # Invalid syntax for shlex (Probably due to missing closing quote) - return [] - - if len(tokens) == 0: - return [] - - # Check if we are still completing the shell command - if len(tokens) == 1: - - # Readline places begidx after ~ and path separators (/) so we need to get the whole token - # and see if it begins with a possible path in case we need to do path completion - # to find the shell command executables - cmd_token = line[prev_space_index + 1:begidx + 1] - - # Don't tab complete anything if no shell command has been started - if len(cmd_token) == 0: - return [] - - # Look for path characters in the token - if not (cmd_token.startswith('~') or os.path.sep in cmd_token): - # No path characters are in this token, it is OK to try shell command completion. - command_completions = self._get_exes_in_path(text) - - if command_completions: - # If there is only 1 match and it's at the end of the line, then add a space - if len(command_completions) == 1 and endidx == len(line): - command_completions[0] += ' ' - return command_completions - - # If we have no results, try path completion to find the shell commands - return path_complete(text, line, begidx, endidx, dir_exe_only=True) - - # We are past the shell command, so do path completion - else: - return path_complete(text, line, begidx, endidx) - - def cmd_with_subs_completer(self, text, line, begidx, endidx, base): + def cmd_with_subs_completer(self, text, line, begidx, endidx): """ This is a function provided for convenience to those who want an easy way to add tab completion to functions that implement subcommands. By setting this as the completer of the base command function, the correct completer for the chosen subcommand will be called. - The use of this function requires a particular naming scheme. + The use of this function requires assigning a completer function to the subcommand's parser Example: - A command called print has 2 subcommands [names, addresses] - The tab-completion functions for the subcommands must be called: - names -> complete_print_names - addresses -> complete_print_addresses + A command called print has a subcommands called 'names' that needs a tab completer + When you create the parser for names, include the completer function in the parser's defaults. - To make sure these functions get called, set the tab-completer for the print function - in a similar fashion to what follows where base is the name of the root command (print) + names_parser.set_defaults(func=print_names, completer=complete_print_names) - complete_print = functools.partialmethod(cmd2.Cmd.cmd_with_subs_completer, base='print') + To make sure the names completer gets called, set the completer for the print function + in a similar fashion to what follows. - When the subcommand's completer is called, this function will have stripped off all content from the - beginning of he command line before the subcommand, meaning the line parameter always starts with the - subcommand name and the index parameters reflect this change. + complete_print = cmd2.Cmd.cmd_with_subs_completer - For instance, the command "print names -d 2" becomes "names -d 2" - begidx and endidx are incremented accordingly + When the subcommand's completer is called, this function will have stripped off all content from the + beginning of the command line before the subcommand, meaning the line parameter always starts with the + subcommand name and the index parameters reflect this change. + + For instance, the command "print names -d 2" becomes "names -d 2" + begidx and endidx are incremented accordingly :param text: str - the string prefix we are attempting to match (all returned matches must begin with it) :param line: str - the current input line with leading whitespace removed :param begidx: int - the beginning index of the prefix text :param endidx: int - the ending index of the prefix text - :param base: str - the name of the base command that owns the subcommands - :return: List[str] - a list of possible tab completions + :return: List[str] - a sorted list of possible tab completions """ + # The command is the token at index 0 in the command line + cmd_index = 0 # The subcommand is the token at index 1 in the command line subcmd_index = 1 - # Get all tokens prior to token being completed - try: - prev_space_index = max(line.rfind(' ', 0, begidx), 0) - tokens = shlex.split(line[:prev_space_index], posix=POSIX_SHLEX) - except ValueError: - # Invalid syntax for shlex (Probably due to missing closing quote) + # Get all tokens through the one being completed + tokens, _ = self.tokens_for_completion(line, begidx, endidx) + if tokens is None: return [] - completions = [] + matches = [] # Get the index of the token being completed - index = len(tokens) + index = len(tokens) - 1 # If the token being completed is past the subcommand name, then do subcommand specific tab-completion if index > subcmd_index: + # Get the command name + command = tokens[cmd_index] + # Get the subcommand name subcommand = tokens[subcmd_index] @@ -2446,15 +3171,12 @@ def cmd_with_subs_completer(self, text, line, begidx, endidx, base): begidx -= diff endidx -= diff - # Call the subcommand specific completer - completer = 'complete_{}_{}'.format(base, subcommand) - try: - compfunc = getattr(self, completer) - completions = compfunc(text, line, begidx, endidx) - except AttributeError: - pass + # Call the subcommand specific completer if it exists + compfunc = self.get_subcommand_completer(command, subcommand) + if compfunc is not None: + matches = compfunc(self, text, line, begidx, endidx) - return completions + return matches # noinspection PyBroadException def do_py(self, arg): @@ -2563,7 +3285,9 @@ def do_pyscript(self, arglist): sys.argv = orig_args # Enable tab-completion for pyscript command - complete_pyscript = functools.partial(path_complete) + def complete_pyscript(self, text, line, begidx, endidx): + index_dict = {1: self.path_complete} + return self.index_based_complete(text, line, begidx, endidx, index_dict) # Only include the do_ipy() method if IPython is available on the system if ipython_available: @@ -2705,7 +3429,9 @@ def do_edit(self, arglist): os.system('"{}"'.format(self.editor)) # Enable tab-completion for edit command - complete_edit = functools.partial(path_complete) + def complete_edit(self, text, line, begidx, endidx): + index_dict = {1: self.path_complete} + return self.index_based_complete(text, line, begidx, endidx, index_dict) @property def _current_script_dir(self): @@ -2794,7 +3520,9 @@ def do_load(self, arglist): self._script_dir.append(os.path.dirname(expanded_path)) # Enable tab-completion for load command - complete_load = functools.partial(path_complete) + def complete_load(self, text, line, begidx, endidx): + index_dict = {1: self.path_complete} + return self.index_based_complete(text, line, begidx, endidx, index_dict) @staticmethod def is_text_file(file_path): diff --git a/docs/freefeatures.rst b/docs/freefeatures.rst index 740ea0675..8255868c6 100644 --- a/docs/freefeatures.rst +++ b/docs/freefeatures.rst @@ -333,8 +333,7 @@ Additionally, it is trivial to add identical file system path completion to your have defined a custom command ``foo`` by implementing the ``do_foo`` method. To enable path completion for the ``foo`` command, then add a line of code similar to the following to your class which inherits from ``cmd2.Cmd``:: - # Make sure you have an "import functools" somewhere at the top - complete_foo = functools.partial(path_complete) + complete_foo = self.path_complete This will effectively define the ``complete_foo`` readline completer method in your class and make it utilize the same path completion logic as the built-in commands. @@ -345,4 +344,9 @@ path completion of directories only for this command by adding a line of code si which inherits from ``cmd2.Cmd``:: # Make sure you have an "import functools" somewhere at the top - complete_bar = functools.partial(path_complete, dir_only=True) + complete_bar = functools.partialmethod(cmd2.Cmd.path_complete, dir_only=True) + + # Since Python 2 does not have functools.partialmethod(), you can achieve the + # same thing by implementing a tab completion function + def complete_bar(self, text, line, begidx, endidx): + return self.path_complete(text, line, begidx, endidx, dir_only=True) diff --git a/docs/requirements.txt b/docs/requirements.txt index b50df7d1e..b8cf9271a 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,3 +2,5 @@ pyparsing six pyperclip contextlib2 +enum34 +subprocess32 diff --git a/examples/paged_output.py b/examples/paged_output.py index 9005a4da4..cb213087b 100755 --- a/examples/paged_output.py +++ b/examples/paged_output.py @@ -2,7 +2,6 @@ # coding=utf-8 """A simple example demonstrating the using paged output via the ppaged() method. """ -import functools import cmd2 from cmd2 import with_argument_list @@ -25,7 +24,7 @@ def do_page_file(self, args): text = f.read() self.ppaged(text) - complete_page_file = functools.partial(cmd2.path_complete) + complete_page_file = cmd2.Cmd.path_complete if __name__ == '__main__': diff --git a/examples/python_scripting.py b/examples/python_scripting.py index f46062513..5f7996e29 100755 --- a/examples/python_scripting.py +++ b/examples/python_scripting.py @@ -15,7 +15,6 @@ This application and the "scripts/conditional.py" script serve as an example for one way in which this can be done. """ import argparse -import functools import os import cmd2 @@ -82,8 +81,9 @@ def do_cd(self, arglist): self.perror(err, traceback_war=False) self._last_result = cmd2.CmdResult(out, err) - # Enable directory completion for cd command by freezing an argument to path_complete() with functools.partial - complete_cd = functools.partial(cmd2.path_complete, dir_only=True) + # Enable tab completion for cd command + def complete_cd(self, text, line, begidx, endidx): + return self.path_complete(text, line, begidx, endidx, dir_only=True) dir_parser = argparse.ArgumentParser() dir_parser.add_argument('-l', '--long', action='store_true', help="display in long format with one item per line") diff --git a/examples/remove_unused.py b/examples/remove_unused.py index 2de0e9b64..cf26fcff0 100755 --- a/examples/remove_unused.py +++ b/examples/remove_unused.py @@ -2,8 +2,8 @@ # coding=utf-8 """A simple example demonstrating how to remove unused commands. -Commands can be removed from the help menu by appending their full command name (including "do_") to the -"exclude_from_help" list. These commands will still exist and can be executed and help can be retrieved for them by +Commands can be removed from help menu and tab completion by appending their command name to the hidden_commands list. +These commands will still exist and can be executed and help can be retrieved for them by name, they just won't clutter the help menu. Commands can also be removed entirely by using Python's "del". @@ -18,8 +18,8 @@ class RemoveUnusedBuiltinCommands(cmd2.Cmd): def __init__(self): cmd2.Cmd.__init__(self) - # To hide commands from displaying in the help menu, add their function name to the exclude_from_help list - self.exclude_from_help.append('do_py') + # To hide commands from displaying in the help menu, add them to the hidden_commands list + self.hidden_commands.append('py') # To remove built-in commands entirely, delete their "do_*" function from the cmd2.Cmd class del cmd2.Cmd.do_edit diff --git a/examples/subcommands.py b/examples/subcommands.py index bc82b5487..cbe4f634d 100755 --- a/examples/subcommands.py +++ b/examples/subcommands.py @@ -7,15 +7,17 @@ and provides separate contextual help. """ import argparse -import functools -import sys import cmd2 -from cmd2 import with_argparser, index_based_complete +from cmd2 import with_argparser +sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] class SubcommandsExample(cmd2.Cmd): - """ Example cmd2 application where we a base command which has a couple subcommands.""" + """ + Example cmd2 application where we a base command which has a couple subcommands + and the "sport" subcommand has tab completion enabled. + """ def __init__(self): cmd2.Cmd.__init__(self) @@ -36,9 +38,8 @@ def base_sport(self, args): # noinspection PyUnusedLocal def complete_base_sport(self, text, line, begidx, endidx): """ Adds tab completion to base sport subcommand """ - sports = ['Football', 'Hockey', 'Soccer', 'Baseball'] - index_dict = {1: sports} - return index_based_complete(text, line, begidx, endidx, index_dict) + index_dict = {1: sport_item_strs} + return self.index_based_complete(text, line, begidx, endidx, index_dict) # create the top-level parser for the base command base_parser = argparse.ArgumentParser(prog='base') @@ -58,7 +59,9 @@ def complete_base_sport(self, text, line, begidx, endidx): # create the parser for the "sport" subcommand parser_sport = base_subparsers.add_parser('sport', help='sport help') parser_sport.add_argument('sport', help='Enter name of a sport') - parser_sport.set_defaults(func=base_sport) + + # Set both a function and tab completer for the "sport" subcommand + parser_sport.set_defaults(func=base_sport, completer=complete_base_sport) @with_argparser(base_parser) def do_base(self, args): @@ -71,10 +74,8 @@ def do_base(self, args): # No subcommand was provided, so call help self.do_help('base') - # functools.partialmethod was added in Python 3.4 - if sys.version_info >= (3, 4): - # This makes sure correct tab completion functions are called based on the selected subcommand - complete_base = functools.partialmethod(cmd2.Cmd.cmd_with_subs_completer, base='base') + # Enable tab completion of base to make sure the subcommands' completers get called. + complete_base = cmd2.Cmd.cmd_with_subs_completer if __name__ == '__main__': diff --git a/examples/tab_completion.py b/examples/tab_completion.py index 6c16e63bf..93d6c0efc 100755 --- a/examples/tab_completion.py +++ b/examples/tab_completion.py @@ -3,34 +3,14 @@ """A simple example demonstrating how to use flag and index based tab-completion functions """ import argparse -import functools import cmd2 -from cmd2 import with_argparser, with_argument_list, flag_based_complete, index_based_complete, path_complete +from cmd2 import with_argparser, with_argument_list # List of strings used with flag and index based completion functions food_item_strs = ['Pizza', 'Hamburger', 'Ham', 'Potato'] sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football'] -# Dictionary used with flag based completion functions -flag_dict = \ - { - '-f': food_item_strs, # Tab-complete food items after -f flag in command line - '--food': food_item_strs, # Tab-complete food items after --food flag in command line - '-s': sport_item_strs, # Tab-complete sport items after -s flag in command line - '--sport': sport_item_strs, # Tab-complete sport items after --sport flag in command line - '-o': path_complete, # Tab-complete using path_complete function after -o flag in command line - '--other': path_complete, # Tab-complete using path_complete function after --other flag in command line - } - -# Dictionary used with index based completion functions -index_dict = \ - { - 1: food_item_strs, # Tab-complete food items at index 1 in command line - 2: sport_item_strs, # Tab-complete sport items at index 2 in command line - 3: path_complete, # Tab-complete using path_complete function at index 3 in command line - } - class TabCompleteExample(cmd2.Cmd): """ Example cmd2 application where we a base command which has a couple subcommands.""" @@ -59,7 +39,23 @@ def do_add_item(self, args): self.poutput("You added {}".format(add_item)) # Add flag-based tab-completion to add_item command - complete_add_item = functools.partial(flag_based_complete, flag_dict=flag_dict) + def complete_add_item(self, text, line, begidx, endidx): + flag_dict = \ + { + # Tab-complete food items after -f and --food flags in command line + '-f': food_item_strs, + '--food': food_item_strs, + + # Tab-complete sport items after -s and --sport flags in command line + '-s': sport_item_strs, + '--sport': sport_item_strs, + + # Tab-complete using path_complete function after -o and --other flags in command line + '-o': self.path_complete, + '--other': self.path_complete, + } + + return self.flag_based_complete(text, line, begidx, endidx, flag_dict=flag_dict) @with_argument_list def do_list_item(self, args): @@ -67,7 +63,15 @@ def do_list_item(self, args): self.poutput("You listed {}".format(args)) # Add index-based tab-completion to list_item command - complete_list_item = functools.partial(index_based_complete, index_dict=index_dict) + def complete_list_item(self, text, line, begidx, endidx): + index_dict = \ + { + 1: food_item_strs, # Tab-complete food items at index 1 in command line + 2: sport_item_strs, # Tab-complete sport items at index 2 in command line + 3: self.path_complete, # Tab-complete using path_complete function at index 3 in command line + } + + return self.index_based_complete(text, line, begidx, endidx, index_dict=index_dict) if __name__ == '__main__': diff --git a/setup.py b/setup.py index 44268767f..8107552d2 100755 --- a/setup.py +++ b/setup.py @@ -71,6 +71,8 @@ ":sys_platform=='win32'": ['pyreadline'], # Python 3.4 and earlier require contextlib2 for temporarily redirecting stderr and stdout ":python_version<'3.5'": ['contextlib2'], + # Python 3.3 and earlier require enum34 backport of enum module from Python 3.4 + ":python_version<'3.4'": ['enum34'], # Python 2.7 also requires subprocess32 ":python_version<'3.0'": ['subprocess32'], } @@ -81,6 +83,8 @@ INSTALL_REQUIRES.append('pyreadline') if sys.version_info < (3, 5): INSTALL_REQUIRES.append('contextlib2') + if sys.version_info < (3, 4): + INSTALL_REQUIRES.append('enum34') if sys.version_info < (3, 0): INSTALL_REQUIRES.append('subprocess32') diff --git a/tests/test_argparse.py b/tests/test_argparse.py index ae3bde980..02c1701b4 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -3,13 +3,10 @@ Cmd2 testing for argument parsing """ import argparse -import functools import pytest -import sys import cmd2 import mock -import six from conftest import run_cmd, StdOut @@ -194,20 +191,9 @@ def base_foo(self, args): self.poutput(args.x * args.y) def base_bar(self, args): - """bar sucommand of base command""" + """bar subcommand of base command""" self.poutput('((%s))' % args.z) - def base_sport(self, args): - """sport subcommand of base command""" - self.poutput('Sport is {}'.format(args.sport)) - - # noinspection PyUnusedLocal - def complete_base_sport(self, text, line, begidx, endidx): - """ Adds tab completion to base sport subcommand """ - sports = ['Football', 'Hockey', 'Soccer', 'Baseball'] - index_dict = {1: sports} - return cmd2.index_based_complete(text, line, begidx, endidx, index_dict) - # create the top-level parser for the base command base_parser = argparse.ArgumentParser(prog='base') base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help') @@ -223,26 +209,17 @@ def complete_base_sport(self, text, line, begidx, endidx): parser_bar.add_argument('z', help='string') parser_bar.set_defaults(func=base_bar) - # create the parser for the "sport" subcommand - parser_sport = base_subparsers.add_parser('sport', help='sport help') - parser_sport.add_argument('sport', help='Enter name of a sport') - parser_sport.set_defaults(func=base_sport) - - @cmd2.with_argparser_and_unknown_args(base_parser) - def do_base(self, args, arglist): + @cmd2.with_argparser(base_parser) + def do_base(self, args): """Base command help""" - try: + func = getattr(args, 'func', None) + if func is not None: # Call whatever subcommand function was selected - args.func(self, args) - except AttributeError: - # No subcommand was provided, so as called + func(self, args) + else: + # No subcommand was provided, so call help self.do_help('base') - # functools.partialmethod was added in Python 3.4 - if six.PY3: - # This makes sure correct tab completion functions are called based on the selected subcommand - complete_base = functools.partialmethod(cmd2.Cmd.cmd_with_subs_completer, base='base') - @pytest.fixture def subcommand_app(): app = SubcommandApp() @@ -283,57 +260,3 @@ def test_subcommand_invalid_help(subcommand_app): out = run_cmd(subcommand_app, 'help base baz') assert out[0].startswith('usage: base') assert out[1].startswith("base: error: invalid choice: 'baz'") - -@pytest.mark.skipif(sys.version_info < (3,0), reason="functools.partialmethod requires Python 3.4+") -def test_subcommand_tab_completion(subcommand_app): - # This makes sure the correct completer for the sport subcommand is called - text = 'Foot' - line = 'base sport Foot' - endidx = len(line) - begidx = endidx - len(text) - state = 0 - - def get_line(): - return line - - def get_begidx(): - return begidx - - def get_endidx(): - return endidx - - with mock.patch.object(readline, 'get_line_buffer', get_line): - with mock.patch.object(readline, 'get_begidx', get_begidx): - with mock.patch.object(readline, 'get_endidx', get_endidx): - # Run the readline tab-completion function with readline mocks in place - first_match = subcommand_app.complete(text, state) - - # It is at end of line, so extra space is present - assert first_match is not None and subcommand_app.completion_matches == ['Football '] - -@pytest.mark.skipif(sys.version_info < (3,0), reason="functools.partialmethod requires Python 3.4+") -def test_subcommand_tab_completion_with_no_completer(subcommand_app): - # This tests what happens when a subcommand has no completer - # In this case, the foo subcommand has no completer defined - text = 'Foot' - line = 'base foo Foot' - endidx = len(line) - begidx = endidx - len(text) - state = 0 - - def get_line(): - return line - - def get_begidx(): - return begidx - - def get_endidx(): - return endidx - - with mock.patch.object(readline, 'get_line_buffer', get_line): - with mock.patch.object(readline, 'get_begidx', get_begidx): - with mock.patch.object(readline, 'get_endidx', get_endidx): - # Run the readline tab-completion function with readline mocks in place - first_match = subcommand_app.complete(text, state) - - assert first_match is None diff --git a/tests/test_completion.py b/tests/test_completion.py index daeb94bf3..e779e44ba 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -16,8 +16,6 @@ import mock import pytest -from cmd2 import path_complete, basic_complete, flag_based_complete, index_based_complete - # Prefer statically linked gnureadline if available (for macOS compatibility due to issues with libedit) try: import gnureadline as readline @@ -36,27 +34,47 @@ def cmd2_app(): c = cmd2.Cmd() return c -@pytest.fixture -def cs_app(): - c = cmd2.Cmd() - return c +# List of strings used with completion functions +food_item_strs = ['Pizza', 'Hamburger', 'Ham', 'Potato'] +sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] +delimited_strs = ['/home/user/file.txt', '/home/user/prog.c', '/home/otheruser/maps'] -def test_cmd2_command_completion_single_end(cmd2_app): - text = 'he' - line = 'he' - endidx = len(line) - begidx = endidx - len(text) - # It is at end of line, so extra space is present - assert cmd2_app.completenames(text, line, begidx, endidx) == ['help '] +# Dictionary used with flag based completion functions +flag_dict = \ + { + # Tab-complete food items after -f and --food flag in command line + '-f': food_item_strs, + '--food': food_item_strs, -def test_complete_command_single_end(cmd2_app): - text = 'he' - line = 'he' - state = 0 - endidx = len(line) - begidx = endidx - len(text) + # Tab-complete sport items after -s and --sport flag in command line + '-s': sport_item_strs, + '--sport': sport_item_strs, + } + +# Dictionary used with index based completion functions +index_dict = \ + { + 1: food_item_strs, # Tab-complete food items at index 1 in command line + 2: sport_item_strs, # Tab-complete sport items at index 2 in command line + } +def complete_tester(text, line, begidx, endidx, app): + """ + This is a convenience function to test cmd2.complete() since + in a unit test environment there is no actual console readline + is monitoring. Therefore we use mock to provide readline data + to complete(). + + :param text: str - the string prefix we are attempting to match + :param line: str - the current input line with leading whitespace removed + :param begidx: int - the beginning index of the prefix text + :param endidx: int - 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 have been sorted by complete() + """ def get_line(): return line @@ -66,169 +84,128 @@ def get_begidx(): def get_endidx(): return endidx + first_match = None with mock.patch.object(readline, 'get_line_buffer', get_line): with mock.patch.object(readline, 'get_begidx', get_begidx): with mock.patch.object(readline, 'get_endidx', get_endidx): # Run the readline tab-completion function with readline mocks in place - first_match = cmd2_app.complete(text, state) + first_match = app.complete(text, 0) + + return first_match - assert first_match is not None and cmd2_app.completion_matches == ['help '] -def test_complete_command_invalid_state(cmd2_app): +def test_cmd2_command_completion_single(cmd2_app): text = 'he' - line = 'he' - state = 1 + line = text endidx = len(line) begidx = endidx - len(text) + assert cmd2_app.completenames(text, line, begidx, endidx) == ['help'] - def get_line(): - return line - - def get_begidx(): - return begidx - - def get_endidx(): - return endidx - - with mock.patch.object(readline, 'get_line_buffer', get_line): - with mock.patch.object(readline, 'get_begidx', get_begidx): - with mock.patch.object(readline, 'get_endidx', get_endidx): - # Run the readline tab-completion function with readline mocks in place get None - first_match = cmd2_app.complete(text, state) +def test_complete_command_single(cmd2_app): + text = 'he' + line = text + endidx = len(line) + begidx = endidx - len(text) - assert first_match is None + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is not None and cmd2_app.completion_matches == ['help '] def test_complete_empty_arg(cmd2_app): text = '' - line = 'help ' - state = 0 + line = 'help {}'.format(text) endidx = len(line) begidx = endidx - len(text) - def get_line(): - return line - - def get_begidx(): - return begidx - - def get_endidx(): - return endidx - - with mock.patch.object(readline, 'get_line_buffer', get_line): - with mock.patch.object(readline, 'get_begidx', get_begidx): - with mock.patch.object(readline, 'get_endidx', get_endidx): - # Run the readline tab-completion function with readline mocks in place - first_match = cmd2_app.complete(text, state) + expected = cmd2_app.complete_help(text, line, begidx, endidx) + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) assert first_match is not None and \ - cmd2_app.completion_matches == cmd2_app.complete_help(text, line, begidx, endidx) + cmd2_app.completion_matches == expected def test_complete_bogus_command(cmd2_app): text = '' - line = 'fizbuzz ' - state = 0 + line = 'fizbuzz {}'.format(text) endidx = len(line) begidx = endidx - len(text) - def get_line(): - return line - - def get_begidx(): - return begidx - - def get_endidx(): - return endidx - - with mock.patch.object(readline, 'get_line_buffer', get_line): - with mock.patch.object(readline, 'get_begidx', get_begidx): - with mock.patch.object(readline, 'get_endidx', get_endidx): - # Run the readline tab-completion function with readline mocks in place - first_match = cmd2_app.complete(text, state) - + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) assert first_match is None -def test_cmd2_command_completion_is_case_sensitive(cmd2_app): - text = 'HE' - line = 'HE' + +def test_cmd2_command_completion_single(cmd2_app): + text = 'hel' + line = text endidx = len(line) begidx = endidx - len(text) - # It is at end of line, so extra space is present - assert cmd2_app.completenames(text, line, begidx, endidx) == [] - -def test_cmd2_command_completion_single_mid(cmd2_app): - text = 'he' - line = 'he' - begidx = 0 - endidx = 1 - # It is not at end of line, so no extra space assert cmd2_app.completenames(text, line, begidx, endidx) == ['help'] def test_cmd2_command_completion_multiple(cmd2_app): text = 'h' - line = 'h' + line = text endidx = len(line) begidx = endidx - len(text) - # It is not at end of line, so no extra space assert cmd2_app.completenames(text, line, begidx, endidx) == ['help', 'history'] def test_cmd2_command_completion_nomatch(cmd2_app): - text = 'z' - line = 'z' + text = 'fakecommand' + line = text endidx = len(line) begidx = endidx - len(text) assert cmd2_app.completenames(text, line, begidx, endidx) == [] -def test_cmd2_help_completion_single_end(cmd2_app): + +def test_cmd2_help_completion_single(cmd2_app): text = 'he' - line = 'help he' + line = 'help {}'.format(text) endidx = len(line) begidx = endidx - len(text) - # Even though it is at end of line, no extra space is present when tab completing a command name to get help on - assert cmd2_app.complete_help(text, line, begidx, endidx) == ['help'] - -def test_cmd2_help_completion_single_mid(cmd2_app): - text = 'he' - line = 'help he' - begidx = 5 - endidx = 6 assert cmd2_app.complete_help(text, line, begidx, endidx) == ['help'] def test_cmd2_help_completion_multiple(cmd2_app): text = 'h' - line = 'help h' + line = 'help {}'.format(text) endidx = len(line) begidx = endidx - len(text) + assert cmd2_app.complete_help(text, line, begidx, endidx) == ['help', 'history'] def test_cmd2_help_completion_nomatch(cmd2_app): - text = 'z' - line = 'help z' + text = 'fakecommand' + line = 'help {}'.format(text) endidx = len(line) begidx = endidx - len(text) assert cmd2_app.complete_help(text, line, begidx, endidx) == [] -def test_shell_command_completion(cmd2_app): + +def test_shell_command_completion_shortcut(cmd2_app): + # Made sure ! runs a shell command and all matches start with ! since there + # isn't a space between ! and the shell command. Display matches won't + # begin with the !. if sys.platform == "win32": - text = 'calc' - line = 'shell {}'.format(text) - expected = ['calc.exe '] + text = '!calc' + expected = ['!calc.exe '] + expected_display = ['calc.exe'] else: - text = 'egr' - line = 'shell {}'.format(text) - expected = ['egrep '] + text = '!egr' + expected = ['!egrep '] + expected_display = ['egrep'] + line = text endidx = len(line) - begidx = endidx - len(text) - assert cmd2_app.complete_shell(text, line, begidx, endidx) == expected + begidx = 0 + + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is not None and \ + cmd2_app.completion_matches == expected and \ + cmd2_app.display_matches == expected_display def test_shell_command_completion_doesnt_match_wildcards(cmd2_app): if sys.platform == "win32": text = 'c*' - line = 'shell {}'.format(text) else: text = 'e*' - line = 'shell {}'.format(text) + line = 'shell {}'.format(text) endidx = len(line) begidx = endidx - len(text) assert cmd2_app.complete_shell(text, line, begidx, endidx) == [] @@ -236,100 +213,82 @@ def test_shell_command_completion_doesnt_match_wildcards(cmd2_app): def test_shell_command_completion_multiple(cmd2_app): if sys.platform == "win32": text = 'c' - line = 'shell {}'.format(text) expected = 'calc.exe' else: text = 'l' - line = 'shell {}'.format(text) expected = 'ls' + line = 'shell {}'.format(text) endidx = len(line) begidx = endidx - len(text) assert expected in cmd2_app.complete_shell(text, line, begidx, endidx) def test_shell_command_completion_nomatch(cmd2_app): text = 'zzzz' - line = 'shell zzzz' + line = 'shell {}'.format(text) endidx = len(line) begidx = endidx - len(text) assert cmd2_app.complete_shell(text, line, begidx, endidx) == [] def test_shell_command_completion_doesnt_complete_when_just_shell(cmd2_app): text = '' - line = 'shell' - + line = 'shell {}'.format(text) endidx = len(line) - begidx = 0 + begidx = endidx - len(text) assert cmd2_app.complete_shell(text, line, begidx, endidx) == [] def test_shell_command_completion_does_path_completion_when_after_command(cmd2_app, request): test_dir = os.path.dirname(request.module.__file__) - text = 'c' - path = os.path.join(test_dir, text) - line = 'shell cat {}'.format(path) + text = os.path.join(test_dir, 'conftest') + line = 'shell cat {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.complete_shell(text, line, begidx, endidx) == ['conftest.py '] + assert cmd2_app.complete_shell(text, line, begidx, endidx) == [text + '.py'] -def test_path_completion_single_end(request): +def test_path_completion_single_end(cmd2_app, request): test_dir = os.path.dirname(request.module.__file__) - text = 'c' - path = os.path.join(test_dir, text) - line = 'shell cat {}'.format(path) + text = os.path.join(test_dir, 'conftest') + line = 'shell cat {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert path_complete(text, line, begidx, endidx) == ['conftest.py '] + assert cmd2_app.path_complete(text, line, begidx, endidx) == [text + '.py'] -def test_path_completion_single_mid(request): +def test_path_completion_multiple(cmd2_app, request): test_dir = os.path.dirname(request.module.__file__) - text = 'tes' - path = os.path.join(test_dir, 'c') - line = 'shell cat {}'.format(path) - - begidx = line.find(text) - endidx = begidx + len(text) - - assert path_complete(text, line, begidx, endidx) == ['tests' + os.path.sep] - -def test_path_completion_multiple(request): - test_dir = os.path.dirname(request.module.__file__) - - text = 's' - path = os.path.join(test_dir, text) - line = 'shell cat {}'.format(path) + text = os.path.join(test_dir, 's') + line = 'shell cat {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert path_complete(text, line, begidx, endidx) == ['script.py', 'script.txt', 'scripts' + os.path.sep] + expected = [text + 'cript.py', text + 'cript.txt', text + 'cripts' + os.path.sep] + assert expected == cmd2_app.path_complete(text, line, begidx, endidx) -def test_path_completion_nomatch(request): +def test_path_completion_nomatch(cmd2_app, request): test_dir = os.path.dirname(request.module.__file__) - text = 'z' - path = os.path.join(test_dir, text) - line = 'shell cat {}'.format(path) + text = os.path.join(test_dir, 'fakepath') + line = 'shell cat {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert path_complete(text, line, begidx, endidx) == [] + assert cmd2_app.path_complete(text, line, begidx, endidx) == [] def test_default_to_shell_completion(cmd2_app, request): cmd2_app.default_to_shell = True test_dir = os.path.dirname(request.module.__file__) - text = 'c' - path = os.path.join(test_dir, text) + text = os.path.join(test_dir, 'conftest') if sys.platform == "win32": command = 'calc.exe' @@ -337,329 +296,310 @@ def test_default_to_shell_completion(cmd2_app, request): command = 'egrep' # Make sure the command is on the testing system - assert command in cmd2.Cmd._get_exes_in_path(command) - line = '{} {}'.format(command, path) + assert command in cmd2_app.get_exes_in_path(command) + line = '{} {}'.format(command, text) endidx = len(line) begidx = endidx - len(text) - state = 0 - - def get_line(): - return line - def get_begidx(): - return begidx + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is not None and cmd2_app.completion_matches == [text + '.py '] - def get_endidx(): - return endidx - with mock.patch.object(readline, 'get_line_buffer', get_line): - with mock.patch.object(readline, 'get_begidx', get_begidx): - with mock.patch.object(readline, 'get_endidx', get_endidx): - # Run the readline tab-completion function with readline mocks in place - first_match = cmd2_app.complete(text, state) - - assert first_match is not None and cmd2_app.completion_matches == ['conftest.py '] - - -def test_path_completion_cwd(): - # Run path complete with no path and no search text +def test_path_completion_cwd(cmd2_app): + # Run path complete with no search text text = '' line = 'shell ls {}'.format(text) endidx = len(line) begidx = endidx - len(text) - completions_empty = path_complete(text, line, begidx, endidx) + completions_no_text = cmd2_app.path_complete(text, line, begidx, endidx) # Run path complete with path set to the CWD - cwd = os.getcwd() + os.path.sep - line = 'shell ls {}'.format(cwd) + text = os.getcwd() + os.path.sep + line = 'shell ls {}'.format(text) endidx = len(line) begidx = endidx - len(text) - completions_cwd = path_complete(text, line, begidx, endidx) - # Verify that the results are the same in both cases and that there is something there - assert completions_empty == completions_cwd + # We have to strip off the text 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)] + + # Verify that the first test gave results for entries in the cwd + assert completions_no_text == completions_cwd assert completions_cwd -def test_path_completion_doesnt_match_wildcards(request): +def test_path_completion_doesnt_match_wildcards(cmd2_app, request): test_dir = os.path.dirname(request.module.__file__) - text = 'c*' - path = os.path.join(test_dir, text) - line = 'shell cat {}'.format(path) + text = os.path.join(test_dir, 'c*') + line = 'shell cat {}'.format(text) endidx = len(line) begidx = endidx - len(text) # Currently path completion doesn't accept wildcards, so will always return empty results - assert path_complete(text, line, begidx, endidx) == [] + assert cmd2_app.path_complete(text, line, begidx, endidx) == [] -def test_path_completion_invalid_syntax(): +def test_path_completion_invalid_syntax(cmd2_app): # Test a missing separator between a ~ and path - text = '' - line = 'shell fake ~Desktop' + text = '~Desktop' + line = 'shell fake {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert path_complete(text, line, begidx, endidx) == [] + assert cmd2_app.path_complete(text, line, begidx, endidx) == [] -def test_path_completion_just_tilde(): +def test_path_completion_just_tilde(cmd2_app): # Run path with just a tilde - text = '' - line = 'shell fake ~' + text = '~' + line = 'shell fake {}'.format(text) endidx = len(line) begidx = endidx - len(text) - completions_tilde = path_complete(text, line, begidx, endidx) + completions_tilde = cmd2_app.path_complete(text, line, begidx, endidx) - # Path complete should return a slash - assert completions_tilde == [os.path.sep] + # Path complete should complete the tilde with a slash + assert completions_tilde == [text + os.path.sep] -def test_path_completion_user_expansion(): +def test_path_completion_user_expansion(cmd2_app): # Run path with a tilde and a slash - text = '' if sys.platform.startswith('win'): cmd = 'dir' else: cmd = 'ls' - line = 'shell {} ~{}'.format(cmd, os.path.sep) + # Use a ~ which will be expanded into the user's home directory + text = '~{}'.format(os.path.sep) + line = 'shell {} {}'.format(cmd, text) endidx = len(line) begidx = endidx - len(text) - completions_tilde_slash = path_complete(text, line, begidx, endidx) + completions_tilde_slash = [match.replace(text, '', 1) for match in cmd2_app.path_complete(text, line, + begidx, endidx)] # Run path complete on the user's home directory - user_dir = os.path.expanduser('~') + os.path.sep - line = 'shell {} {}'.format(cmd, user_dir) + text = os.path.expanduser('~') + os.path.sep + line = 'shell {} {}'.format(cmd, text) endidx = len(line) begidx = endidx - len(text) - completions_home = path_complete(text, line, begidx, endidx) + completions_home = [match.replace(text, '', 1) for match in cmd2_app.path_complete(text, line, begidx, endidx)] - # Verify that the results are the same in both cases assert completions_tilde_slash == completions_home -def test_path_completion_directories_only(request): +def test_path_completion_directories_only(cmd2_app, request): test_dir = os.path.dirname(request.module.__file__) - text = 's' - path = os.path.join(test_dir, text) - line = 'shell cat {}'.format(path) + text = os.path.join(test_dir, 's') + line = 'shell cat {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert path_complete(text, line, begidx, endidx, dir_only=True) == ['scripts' + os.path.sep] - -def test_path_completion_syntax_err(request): - test_dir = os.path.dirname(request.module.__file__) + expected = [text + 'cripts' + os.path.sep] - text = 'c' - path = os.path.join(test_dir, text) - line = 'shell cat " {}'.format(path) + assert cmd2_app.path_complete(text, line, begidx, endidx, dir_only=True) == expected +def test_basic_completion_single(cmd2_app): + text = 'Pi' + line = 'list_food -f {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert path_complete(text, line, begidx, endidx) == [] + assert cmd2_app.basic_complete(text, line, begidx, endidx, food_item_strs) == ['Pizza'] -def test_path_completion_no_tokens(): +def test_basic_completion_multiple(cmd2_app): text = '' - line = 'shell' - endidx = len(line) - begidx = 0 - assert path_complete(text, line, begidx, endidx) == [] - - -# List of strings used with basic, flag, and index based completion functions -food_item_strs = ['Pizza', 'Hamburger', 'Ham', 'Potato'] -sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football'] - -# Dictionary used with flag based completion functions -flag_dict = \ - { - '-f': food_item_strs, # Tab-complete food items after -f flag in command line - '--food': food_item_strs, # Tab-complete food items after --food flag in command line - '-s': sport_item_strs, # Tab-complete sport items after -s flag in command line - '--sport': sport_item_strs, # Tab-complete sport items after --sport flag in command line - '-o': path_complete, # Tab-complete using path_complete function after -o flag in command line - '--other': path_complete, # Tab-complete using path_complete function after --other flag in command line - } - -def test_basic_completion_single_end(): - text = 'Pi' - line = 'list_food -f Pi' + line = 'list_food -f {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert basic_complete(text, line, begidx, endidx, food_item_strs) == ['Pizza '] - -def test_basic_completion_single_mid(): - text = 'Pi' - line = 'list_food -f Pi' - begidx = len(line) - len(text) - endidx = begidx + 1 - - assert basic_complete(text, line, begidx, endidx, food_item_strs) == ['Pizza'] + assert cmd2_app.basic_complete(text, line, begidx, endidx, food_item_strs) == sorted(food_item_strs) -def test_basic_completion_multiple(): - text = '' - line = 'list_food -f ' +def test_basic_completion_nomatch(cmd2_app): + text = 'q' + line = 'list_food -f {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert basic_complete(text, line, begidx, endidx, food_item_strs) == sorted(food_item_strs) + assert cmd2_app.basic_complete(text, line, begidx, endidx, food_item_strs) == [] -def test_basic_completion_nomatch(): - text = 'q' - line = 'list_food -f q' +def test_delimiter_completion(cmd2_app): + text = '/home/' + line = 'load {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert basic_complete(text, line, begidx, endidx, food_item_strs) == [] + cmd2_app.delimiter_complete(text, line, begidx, endidx, delimited_strs, '/') + + # Remove duplicates from display_matches and sort it. This is typically done in the display function. + display_set = set(cmd2_app.display_matches) + display_list = list(display_set) + display_list.sort() + assert display_list == ['otheruser', 'user'] -def test_flag_based_completion_single_end(): +def test_flag_based_completion_single(cmd2_app): text = 'Pi' - line = 'list_food -f Pi' + line = 'list_food -f {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert flag_based_complete(text, line, begidx, endidx, flag_dict) == ['Pizza '] - -def test_flag_based_completion_single_mid(): - text = 'Pi' - line = 'list_food -f Pi' - begidx = len(line) - len(text) - endidx = begidx + 1 - - assert flag_based_complete(text, line, begidx, endidx, flag_dict) == ['Pizza'] + assert cmd2_app.flag_based_complete(text, line, begidx, endidx, flag_dict) == ['Pizza'] -def test_flag_based_completion_multiple(): +def test_flag_based_completion_multiple(cmd2_app): text = '' - line = 'list_food -f ' + line = 'list_food -f {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert flag_based_complete(text, line, begidx, endidx, flag_dict) == sorted(food_item_strs) + assert cmd2_app.flag_based_complete(text, line, begidx, endidx, flag_dict) == sorted(food_item_strs) -def test_flag_based_completion_nomatch(): +def test_flag_based_completion_nomatch(cmd2_app): text = 'q' - line = 'list_food -f q' + line = 'list_food -f {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert flag_based_complete(text, line, begidx, endidx, flag_dict) == [] + assert cmd2_app.flag_based_complete(text, line, begidx, endidx, flag_dict) == [] -def test_flag_based_default_completer(request): +def test_flag_based_default_completer(cmd2_app, request): test_dir = os.path.dirname(request.module.__file__) - text = 'c' - path = os.path.join(test_dir, text) - line = 'list_food {}'.format(path) + text = os.path.join(test_dir, 'c') + line = 'list_food {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert flag_based_complete(text, line, begidx, endidx, flag_dict, path_complete) == ['conftest.py '] + assert cmd2_app.flag_based_complete(text, line, begidx, endidx, + flag_dict, cmd2_app.path_complete) == [text + 'onftest.py'] -def test_flag_based_callable_completer(request): +def test_flag_based_callable_completer(cmd2_app, request): test_dir = os.path.dirname(request.module.__file__) - text = 'c' - path = os.path.join(test_dir, text) - line = 'list_food -o {}'.format(path) + text = os.path.join(test_dir, 'c') + line = 'list_food -o {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert flag_based_complete(text, line, begidx, endidx, flag_dict, path_complete) == ['conftest.py '] + flag_dict['-o'] = cmd2_app.path_complete + assert cmd2_app.flag_based_complete(text, line, begidx, endidx, + flag_dict) == [text + 'onftest.py'] -def test_flag_based_completion_syntax_err(): - text = 'Pi' - line = 'list_food -f " Pi' + +def test_index_based_completion_single(cmd2_app): + text = 'Foo' + line = 'command Pizza {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert flag_based_complete(text, line, begidx, endidx, flag_dict) == [] + assert cmd2_app.index_based_complete(text, line, begidx, endidx, index_dict) == ['Football'] -def test_flag_based_completion_no_tokens(): +def test_index_based_completion_multiple(cmd2_app): text = '' - line = 'list_food' + line = 'command Pizza {}'.format(text) endidx = len(line) - begidx = 0 + begidx = endidx - len(text) - assert flag_based_complete(text, line, begidx, endidx, flag_dict) == [] + assert cmd2_app.index_based_complete(text, line, begidx, endidx, index_dict) == sorted(sport_item_strs) +def test_index_based_completion_nomatch(cmd2_app): + text = 'q' + line = 'command {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) + assert cmd2_app.index_based_complete(text, line, begidx, endidx, index_dict) == [] -# Dictionary used with index based completion functions -index_dict = \ - { - 1: food_item_strs, # Tab-complete food items at index 1 in command line - 2: sport_item_strs, # Tab-complete sport items at index 2 in command line - 3: path_complete, # Tab-complete using path_complete function at index 3 in command line - } +def test_index_based_default_completer(cmd2_app, request): + test_dir = os.path.dirname(request.module.__file__) + + text = os.path.join(test_dir, 'c') + line = 'command Pizza Bat Computer {}'.format(text) -def test_index_based_completion_single_end(): - text = 'Foo' - line = 'command Pizza Foo' endidx = len(line) begidx = endidx - len(text) - assert index_based_complete(text, line, begidx, endidx, index_dict) == ['Football '] + assert cmd2_app.index_based_complete(text, line, begidx, endidx, + index_dict, cmd2_app.path_complete) == [text + 'onftest.py'] -def test_index_based_completion_single_mid(): - text = 'Foo' - line = 'command Pizza Foo' - begidx = len(line) - len(text) - endidx = begidx + 1 +def test_index_based_callable_completer(cmd2_app, request): + test_dir = os.path.dirname(request.module.__file__) - assert index_based_complete(text, line, begidx, endidx, index_dict) == ['Football'] + text = os.path.join(test_dir, 'c') + line = 'command Pizza Bat {}'.format(text) -def test_index_based_completion_multiple(): - text = '' - line = 'command Pizza ' endidx = len(line) begidx = endidx - len(text) - assert index_based_complete(text, line, begidx, endidx, index_dict) == sorted(sport_item_strs) -def test_index_based_completion_nomatch(): - text = 'q' - line = 'command q' + index_dict[3] = cmd2_app.path_complete + assert cmd2_app.index_based_complete(text, line, begidx, endidx, index_dict) == [text + 'onftest.py'] + + +def test_tokens_for_completion_quoted(cmd2_app): + text = 'Pi' + line = 'list_food "{}"'.format(text) endidx = len(line) - begidx = endidx - len(text) - assert index_based_complete(text, line, begidx, endidx, index_dict) == [] + begidx = endidx -def test_index_based_default_completer(request): - test_dir = os.path.dirname(request.module.__file__) + expected_tokens = ['list_food', 'Pi', ''] + expected_raw_tokens = ['list_food', '"Pi"', ''] - text = 'c' - path = os.path.join(test_dir, text) - line = 'command Pizza Bat Computer {}'.format(path) + tokens, raw_tokens = cmd2_app.tokens_for_completion(line, begidx, endidx) + assert expected_tokens == tokens + assert expected_raw_tokens == raw_tokens +def test_tokens_for_completion_unclosed_quote(cmd2_app): + text = 'Pi' + line = 'list_food "{}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert index_based_complete(text, line, begidx, endidx, index_dict, path_complete) == ['conftest.py '] + expected_tokens = ['list_food', 'Pi'] + expected_raw_tokens = ['list_food', '"Pi'] -def test_index_based_callable_completer(request): - test_dir = os.path.dirname(request.module.__file__) + tokens, raw_tokens = cmd2_app.tokens_for_completion(line, begidx, endidx) + assert expected_tokens == tokens + assert expected_raw_tokens == raw_tokens + +def test_tokens_for_completion_redirect(cmd2_app): + text = '>>file' + line = 'command | < {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) - text = 'c' - path = os.path.join(test_dir, text) - line = 'command Pizza Bat {}'.format(path) + cmd2_app.allow_redirection = True + expected_tokens = ['command', '|', '<', '>>', 'file'] + expected_raw_tokens = ['command', '|', '<', '>>', 'file'] + tokens, raw_tokens = cmd2_app.tokens_for_completion(line, begidx, endidx) + assert expected_tokens == tokens + assert expected_raw_tokens == raw_tokens + +def test_tokens_for_completion_quoted_redirect(cmd2_app): + text = '>file' + line = 'command "{}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert index_based_complete(text, line, begidx, endidx, index_dict) == ['conftest.py '] + cmd2_app.allow_redirection = True + expected_tokens = ['command', '>file'] + expected_raw_tokens = ['command', '">file'] -def test_index_based_completion_syntax_err(): - text = 'Foo' - line = 'command "Pizza Foo' + tokens, raw_tokens = cmd2_app.tokens_for_completion(line, begidx, endidx) + assert expected_tokens == tokens + assert expected_raw_tokens == raw_tokens + +def test_tokens_for_completion_redirect_off(cmd2_app): + text = '>file' + line = 'command {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert index_based_complete(text, line, begidx, endidx, index_dict) == [] + cmd2_app.allow_redirection = False + expected_tokens = ['command', '>file'] + expected_raw_tokens = ['command', '>file'] + tokens, raw_tokens = cmd2_app.tokens_for_completion(line, begidx, endidx) + assert expected_tokens == tokens + assert expected_raw_tokens == raw_tokens def test_parseline_command_and_args(cmd2_app): line = 'help history' @@ -691,12 +631,15 @@ def test_parseline_expands_shortcuts(cmd2_app): class SubcommandsExample(cmd2.Cmd): - """ Example cmd2 application where we a base command which has a couple subcommands.""" + """ + Example cmd2 application where we a base command which has a couple subcommands + and the "sport" subcommand has tab completion enabled. + """ def __init__(self): cmd2.Cmd.__init__(self) - # sub-command functions for the base command + # subcommand functions for the base command def base_foo(self, args): """foo subcommand of base command""" self.poutput(args.x * args.y) @@ -705,21 +648,38 @@ def base_bar(self, args): """bar subcommand of base command""" self.poutput('((%s))' % args.z) + def base_sport(self, args): + """sport subcommand of base command""" + self.poutput('Sport is {}'.format(args.sport)) + + # noinspection PyUnusedLocal + def complete_base_sport(self, text, line, begidx, endidx): + """ Adds tab completion to base sport subcommand """ + index_dict = {1: sport_item_strs} + return self.index_based_complete(text, line, begidx, endidx, index_dict) + # create the top-level parser for the base command base_parser = argparse.ArgumentParser(prog='base') base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help') - # create the parser for the "foo" sub-command + # create the parser for the "foo" subcommand parser_foo = base_subparsers.add_parser('foo', help='foo help') parser_foo.add_argument('-x', type=int, default=1, help='integer') parser_foo.add_argument('y', type=float, help='float') parser_foo.set_defaults(func=base_foo) - # create the parser for the "bar" sub-command + # create the parser for the "bar" subcommand parser_bar = base_subparsers.add_parser('bar', help='bar help') parser_bar.add_argument('z', help='string') parser_bar.set_defaults(func=base_bar) + # create the parser for the "sport" subcommand + parser_sport = base_subparsers.add_parser('sport', help='sport help') + parser_sport.add_argument('sport', help='Enter name of a sport') + + # Set both a function and tab completer for the "sport" subcommand + parser_sport.set_defaults(func=base_sport, completer=complete_base_sport) + @cmd2.with_argparser(base_parser) def do_base(self, args): """Base command help""" @@ -728,9 +688,12 @@ def do_base(self, args): # Call whatever subcommand function was selected func(self, args) else: - # No sub-command was provided, so as called + # No subcommand was provided, so call help self.do_help('base') + # Enable tab completion of base to make sure the subcommands' completers get called. + complete_base = cmd2.Cmd.cmd_with_subs_completer + @pytest.fixture def sc_app(): @@ -740,159 +703,104 @@ def sc_app(): def test_cmd2_subcommand_completion_single_end(sc_app): text = 'f' - line = 'base f' + line = 'base {}'.format(text) endidx = len(line) begidx = endidx - len(text) - state = 0 - - def get_line(): - return line - - def get_begidx(): - return begidx - - def get_endidx(): - return endidx - with mock.patch.object(readline, 'get_line_buffer', get_line): - with mock.patch.object(readline, 'get_begidx', get_begidx): - with mock.patch.object(readline, 'get_endidx', get_endidx): - # Run the readline tab-completion function with readline mocks in place - first_match = sc_app.complete(text, state) + 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 and sc_app.completion_matches == ['foo '] -def test_cmd2_subcommand_completion_single_mid(sc_app): - text = 'f' - line = 'base fo' - endidx = len(line) - 1 - begidx = endidx - len(text) - state = 0 - - def get_line(): - return line - - def get_begidx(): - return begidx - - def get_endidx(): - return endidx - - with mock.patch.object(readline, 'get_line_buffer', get_line): - with mock.patch.object(readline, 'get_begidx', get_begidx): - with mock.patch.object(readline, 'get_endidx', get_endidx): - # Run the readline tab-completion function with readline mocks in place - first_match = sc_app.complete(text, state) - - assert first_match is not None and sc_app.completion_matches == ['foo'] - def test_cmd2_subcommand_completion_multiple(sc_app): text = '' - line = 'base ' + line = 'base {}'.format(text) endidx = len(line) begidx = endidx - len(text) - state = 0 - def get_line(): - return line - - def get_begidx(): - return begidx - - def get_endidx(): - return endidx - - with mock.patch.object(readline, 'get_line_buffer', get_line): - with mock.patch.object(readline, 'get_begidx', get_begidx): - with mock.patch.object(readline, 'get_endidx', get_endidx): - # Run the readline tab-completion function with readline mocks in place - first_match = sc_app.complete(text, state) - - assert first_match is not None and sc_app.completion_matches == ['bar', 'foo'] + first_match = complete_tester(text, line, begidx, endidx, sc_app) + assert first_match is not None and sc_app.completion_matches == ['bar', 'foo', 'sport'] def test_cmd2_subcommand_completion_nomatch(sc_app): text = 'z' - line = 'base z' + line = 'base {}'.format(text) endidx = len(line) begidx = endidx - len(text) - state = 0 - - def get_line(): - return line - - def get_begidx(): - return begidx - - def get_endidx(): - return endidx - - with mock.patch.object(readline, 'get_line_buffer', get_line): - with mock.patch.object(readline, 'get_begidx', get_begidx): - with mock.patch.object(readline, 'get_endidx', get_endidx): - # Run the readline tab-completion function with readline mocks in place - first_match = sc_app.complete(text, state) + first_match = complete_tester(text, line, begidx, endidx, sc_app) assert first_match is None -def test_cmd2_subcommand_completion_after_subcommand(sc_app): - text = 'f' - line = 'base foo f' + +def test_cmd2_help_subcommand_completion_single(sc_app): + text = 'base' + line = 'help {}'.format(text) endidx = len(line) begidx = endidx - len(text) - state = 0 - - def get_line(): - return line - - def get_begidx(): - return begidx + assert sc_app.complete_help(text, line, begidx, endidx) == ['base'] - def get_endidx(): - return endidx +def test_cmd2_help_subcommand_completion_multiple(sc_app): + text = '' + line = 'help base {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) - with mock.patch.object(readline, 'get_line_buffer', get_line): - with mock.patch.object(readline, 'get_begidx', get_begidx): - with mock.patch.object(readline, 'get_endidx', get_endidx): - # Run the readline tab-completion function with readline mocks in place - first_match = sc_app.complete(text, state) + assert sc_app.complete_help(text, line, begidx, endidx) == ['bar', 'foo', 'sport'] - assert first_match is None +def test_cmd2_help_subcommand_completion_nomatch(sc_app): + text = 'z' + line = 'help base {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) + assert sc_app.complete_help(text, line, begidx, endidx) == [] -def test_cmd2_help_subcommand_completion_single_end(sc_app): - text = 'base' - line = 'help base' +def test_subcommand_tab_completion(sc_app): + # This makes sure the correct completer for the sport subcommand is called + text = 'Foot' + line = 'base sport {}'.format(text) endidx = len(line) begidx = endidx - len(text) - # Commands with subcommands have a space at the end when the cursor is at the end of the line - assert sc_app.complete_help(text, line, begidx, endidx) == ['base '] + 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 and sc_app.completion_matches == ['Football '] -def test_cmd2_help_subcommand_completion_single_mid(sc_app): - text = 'ba' - line = 'help base' - begidx = 5 - endidx = 6 - assert sc_app.complete_help(text, line, begidx, endidx) == ['base'] +def test_subcommand_tab_completion_with_no_completer(sc_app): + # This tests what happens when a subcommand has no completer + # In this case, the foo subcommand has no completer defined + text = 'Foot' + line = 'base foo {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) + first_match = complete_tester(text, line, begidx, endidx, sc_app) + assert first_match is None -def test_cmd2_help_subcommand_completion_multiple(sc_app): - text = '' - line = 'help base ' +def test_subcommand_tab_completion_add_quote(sc_app): + # This makes sure an opening quote is added to the readline line buffer + text = 'Space' + line = 'base sport {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert sc_app.complete_help(text, line, begidx, endidx) == ['bar', 'foo'] + first_match = complete_tester(text, line, begidx, endidx, sc_app) -def test_cmd2_help_subcommand_completion_nomatch(sc_app): - text = 'z' - line = 'help base z' + # No matches are returned when an opening quote is added to the screen + assert first_match is None + assert readline.get_line_buffer() == 'base sport "Space Ball" ' + +def test_subcommand_tab_completion_space_in_text(sc_app): + text = 'B' + line = 'base sport "Space {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert sc_app.complete_help(text, line, begidx, endidx) == [] + first_match = complete_tester(text, line, begidx, endidx, sc_app) + + assert first_match is not None and \ + sc_app.completion_matches == ['Ball" '] and \ + sc_app.display_matches == ['Space Ball'] class SecondLevel(cmd2.Cmd): """To be used as a second level command class. """ @@ -933,233 +841,79 @@ def sb_app(): def test_cmd2_submenu_completion_single_end(sb_app): text = 'f' - line = 'second f' + line = 'second {}'.format(text) endidx = len(line) begidx = endidx - len(text) - state = 0 - def get_line(): - return line - - def get_begidx(): - return begidx - - def get_endidx(): - return endidx - - with mock.patch.object(readline, 'get_line_buffer', get_line): - with mock.patch.object(readline, 'get_begidx', get_begidx): - with mock.patch.object(readline, 'get_endidx', get_endidx): - # Run the readline tab-completion function with readline mocks in place - first_match = sb_app.complete(text, state) + first_match = complete_tester(text, line, begidx, endidx, sb_app) # It is at end of line, so extra space is present assert first_match is not None and sb_app.completion_matches == ['foo '] -def test_cmd2_submenu_completion_single_mid(sb_app): - text = 'f' - line = 'second fo' - endidx = len(line) - 1 - begidx = endidx - len(text) - state = 0 - - def get_line(): - return line - - def get_begidx(): - return begidx - - def get_endidx(): - return endidx - - with mock.patch.object(readline, 'get_line_buffer', get_line): - with mock.patch.object(readline, 'get_begidx', get_begidx): - with mock.patch.object(readline, 'get_endidx', get_endidx): - # Run the readline tab-completion function with readline mocks in place - first_match = sb_app.complete(text, state) - - assert first_match is not None and sb_app.completion_matches == ['foo'] - - def test_cmd2_submenu_completion_multiple(sb_app): - text = '' - line = 'second ' + text = 'e' + line = 'second {}'.format(text) endidx = len(line) begidx = endidx - len(text) - state = 0 - def get_line(): - return line + expected = ['edit', 'eof', 'eos'] + first_match = complete_tester(text, line, begidx, endidx, sb_app) - def get_begidx(): - return begidx - - def get_endidx(): - return endidx - - with mock.patch.object(readline, 'get_line_buffer', get_line): - with mock.patch.object(readline, 'get_begidx', get_begidx): - with mock.patch.object(readline, 'get_endidx', get_endidx): - # Run the readline tab-completion function with readline mocks in place - first_match = sb_app.complete(text, state) - - assert first_match is not None and sb_app.completion_matches == [ - '_relative_load', - 'alias', - 'edit', - 'eof', - 'eos', - 'foo', - 'help', - 'history', - 'load', - 'py', - 'pyscript', - 'quit', - 'set', - 'shell', - 'shortcuts', - 'unalias' - ] + assert first_match is not None and sb_app.completion_matches == expected def test_cmd2_submenu_completion_nomatch(sb_app): text = 'z' - line = 'second z' + line = 'second {}'.format(text) endidx = len(line) begidx = endidx - len(text) - state = 0 - - def get_line(): - return line - - def get_begidx(): - return begidx - - def get_endidx(): - return endidx - - with mock.patch.object(readline, 'get_line_buffer', get_line): - with mock.patch.object(readline, 'get_begidx', get_begidx): - with mock.patch.object(readline, 'get_endidx', get_endidx): - # Run the readline tab-completion function with readline mocks in place - first_match = sb_app.complete(text, state) + first_match = complete_tester(text, line, begidx, endidx, sb_app) assert first_match is None def test_cmd2_submenu_completion_after_submenu_match(sb_app): text = 'a' - line = 'second foo a' + line = 'second foo {}'.format(text) endidx = len(line) begidx = endidx - len(text) - state = 0 - - def get_line(): - return line - - def get_begidx(): - return begidx - - def get_endidx(): - return endidx - - with mock.patch.object(readline, 'get_line_buffer', get_line): - with mock.patch.object(readline, 'get_begidx', get_begidx): - with mock.patch.object(readline, 'get_endidx', get_endidx): - # Run the readline tab-completion function with readline mocks in place - first_match = sb_app.complete(text, state) - assert first_match is not None and sb_app.completion_matches == ['asd'] + first_match = complete_tester(text, line, begidx, endidx, sb_app) + assert first_match is not None and sb_app.completion_matches == ['asd '] def test_cmd2_submenu_completion_after_submenu_nomatch(sb_app): text = 'b' - line = 'second foo b' + line = 'second foo {}'.format(text) endidx = len(line) begidx = endidx - len(text) - state = 0 - - def get_line(): - return line - - def get_begidx(): - return begidx - - def get_endidx(): - return endidx - - with mock.patch.object(readline, 'get_line_buffer', get_line): - with mock.patch.object(readline, 'get_begidx', get_begidx): - with mock.patch.object(readline, 'get_endidx', get_endidx): - # Run the readline tab-completion function with readline mocks in place - first_match = sb_app.complete(text, state) + first_match = complete_tester(text, line, begidx, endidx, sb_app) assert first_match is None -def test_cmd2_help_submenu_completion_single_mid(sb_app): - text = 'sec' - line = 'help sec' - begidx = 5 - endidx = 8 - assert sb_app.complete_help(text, line, begidx, endidx) == ['second'] - - def test_cmd2_help_submenu_completion_multiple(sb_app): - text = '' - line = 'help second ' + text = 'p' + line = 'help second {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert sb_app.complete_help(text, line, begidx, endidx) == [ - '_relative_load', - 'alias', - 'edit', - 'eof', - 'eos', - 'foo', - 'help', - 'history', - 'load', - 'py', - 'pyscript', - 'quit', - 'set', - 'shell', - 'shortcuts', - 'unalias' - ] + + assert sb_app.complete_help(text, line, begidx, endidx) == ['py', 'pyscript'] def test_cmd2_help_submenu_completion_nomatch(sb_app): - text = 'b' - line = 'help second b' + text = 'fake' + line = 'help second {}'.format(text) endidx = len(line) begidx = endidx - len(text) assert sb_app.complete_help(text, line, begidx, endidx) == [] def test_cmd2_help_submenu_completion_subcommands(sb_app): - text = '' - line = 'help second ' + text = 'p' + line = 'help second {}'.format(text) endidx = len(line) begidx = endidx - len(text) - assert sb_app.complete_help(text, line, begidx, endidx) == [ - '_relative_load', - 'alias', - 'edit', - 'eof', - 'eos', - 'foo', - 'help', - 'history', - 'load', - 'py', - 'pyscript', - 'quit', - 'set', - 'shell', - 'shortcuts', - 'unalias' - ] + + assert sb_app.complete_help(text, line, begidx, endidx) == ['py', 'pyscript'] diff --git a/tox.ini b/tox.ini index 77a4ad692..1be182f4d 100644 --- a/tox.ini +++ b/tox.ini @@ -12,6 +12,7 @@ setenv = [testenv:py27] deps = codecov + enum34 mock pyparsing pyperclip @@ -28,6 +29,7 @@ commands = [testenv:py27-win] deps = codecov + enum34 mock pyparsing pyperclip