diff options
author | Todd Leonhardt <todd.leonhardt@gmail.com> | 2018-03-02 16:58:01 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-03-02 16:58:01 -0500 |
commit | 17781f27c49b961526c7e3a5302482744e6a038b (patch) | |
tree | cad46f7f9746ecd63c7ee5eb6e4d8eabbb44ba0e | |
parent | 9aeb231a201e070c4897394ff79d96f21753d9b8 (diff) | |
parent | 0ec3c1d40962563cc5f5863e0d824343f43da13d (diff) | |
download | cmd2-git-17781f27c49b961526c7e3a5302482744e6a038b.tar.gz |
Merge pull request #291 from python-cmd2/tab_completion
Tab completion
-rwxr-xr-x | cmd2.py | 543 | ||||
-rwxr-xr-x | examples/python_scripting.py | 22 | ||||
-rwxr-xr-x | examples/subcommands.py | 2 | ||||
-rwxr-xr-x | examples/tab_completion.py | 75 | ||||
-rw-r--r-- | tests/test_completion.py | 386 |
5 files changed, 776 insertions, 252 deletions
@@ -165,6 +165,208 @@ def set_use_arg_list(val): USE_ARG_LIST = val +def flag_based_complete(text, line, begidx, endidx, flag_dict, default_completer=None): + """ + Tab completes based on a particular flag preceding the text 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 default_completer: callable - an optional completer to use if no flags in flag_dict precede the text + being completed + :return: List[str] - a list of possible tab completions + """ + + # Get all tokens prior to text 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 [] + + # Nothing to do + if len(tokens) == 0: + return [] + + completions = [] + flag_processed = False + + # Must have at least the command and one argument for a flag to be present + if len(tokens) > 1: + + # Get the argument that precedes the text being completed + flag = tokens[-1] + + # Check if the flag is in the dictionary + if flag in flag_dict: + + # Check if this flag does completions using an Iterable + if isinstance(flag_dict[flag], collections.Iterable): + flag_processed = True + strs_to_match = flag_dict[flag] + completions = [cur_str for cur_str in strs_to_match 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] += ' ' + + # Otherwise check if this flag does completions with a function + elif callable(flag_dict[flag]): + flag_processed = True + completer_func = flag_dict[flag] + completions = completer_func(text, line, begidx, endidx) + + # Check if we need to run the default completer + if default_completer is not None and not flag_processed: + completions = default_completer(text, line, begidx, endidx) + + completions.sort() + return completions + + +def index_based_complete(text, line, begidx, endidx, index_dict, default_completer=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 default_completer: callable - an optional completer to use if the text being completed is not at + any index in index_dict + :return: List[str] - a list of possible tab completions + """ + + # Get all tokens prior to text 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 [] + + completions = [] + + # Must have at least the command + if len(tokens) > 0: + + # Get the index of the text being completed + index = len(tokens) + + # Check if the index is in the dictionary + if index in index_dict: + + # Check if this index does completions using an Iterable + if isinstance(index_dict[index], collections.Iterable): + strs_to_match = index_dict[index] + completions = [cur_str for cur_str in strs_to_match 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] += ' ' + + # Otherwise check if this index does completions with a function + elif callable(index_dict[index]): + completer_func = index_dict[index] + completions = completer_func(text, line, begidx, endidx) + + # Otherwise check if there is a default completer + elif default_completer is not None: + completions = default_completer(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 + """ + + # 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 + + add_sep_after_tilde = False + # If no path and no search text has been entered, then search in the CWD for * + if not text and line[begidx - 1] == ' ' and (begidx >= len(line) or line[begidx] == ' '): + search_str = os.path.join(os.getcwd(), '*') + else: + # Parse out the path being searched + prev_space_index = line.rfind(' ', 0, begidx) + dirname = line[prev_space_index + 1:begidx] + + # 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 tilde was used without separator, add a separator after the tilde in the completions + add_sep_after_tilde = 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 + 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 for convenience + if os.path.isfile(path_completions[0]) and endidx == len(line): + completions[0] += ' ' + # If tilde was expanded without a separator, prepend one + elif os.path.isdir(path_completions[0]) and add_sep_after_tilde: + completions[0] = os.path.sep + 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. @@ -1029,33 +1231,24 @@ class Cmd(cmd.Cmd): return cmd_completion - # noinspection PyUnusedLocal - def complete_subcommand(self, text, line, begidx, endidx): - """Readline tab-completion method for completing argparse sub-command names.""" - command, args, foo = self.parseline(line) - arglist = args.split() - - if len(arglist) <= 1 and command + ' ' + args == line: - funcname = self._func_named(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) + def get_subcommands(self, command): + """ + Returns a list of a command's subcommands if they exist + :param command: + :return: A subcommand list or None + """ - # If this command has subcommands - if subcommand_names is not None: - arg = '' - if arglist: - arg = arglist[0] + subcommand_names = None - matches = [sc for sc in subcommand_names if sc.startswith(arg)] + # Check if is a valid command + funcname = self._func_named(command) - # If completing the sub-command name and get exactly 1 result and are at end of line, add a space - if len(matches) == 1 and endidx == len(line): - matches[0] += ' ' - return matches + 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) - return [] + return subcommand_names def complete(self, text, state): """Override of command method which returns the next possible completion for 'text'. @@ -1078,42 +1271,115 @@ class Cmd(cmd.Cmd): 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 if begidx > 0: - command, args, foo = self.parseline(line) + + # 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()) + expanded_line += ' ' * rstripped_len + + # Fix the index values if expanded_line has a different size than line + if len(expanded_line) != len(line): + diff = len(expanded_line) - len(line) + begidx += diff + endidx += diff + + # Overwrite line to pass into completers + line = expanded_line + if command == '': compfunc = self.completedefault else: - arglist = args.split() - - compfunc = None - # If the user has entered no more than a single argument after the command name - if len(arglist) <= 1 and command + ' ' + args == line: - funcname = self._func_named(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) - - # If this command has subcommands - if subcommand_names is not None: - compfunc = self.complete_subcommand - - if compfunc is None: - # This command either doesn't have sub-commands or the user is past the point of entering one - try: - compfunc = getattr(self, 'complete_' + command) - except AttributeError: - compfunc = self.completedefault + + # Get the completion function for this command + try: + compfunc = getattr(self, 'complete_' + command) + except AttributeError: + compfunc = self.completedefault + + # If there are subcommands, then try completing those if the cursor is in + # the correct position, otherwise default to using compfunc + subcommands = self.get_subcommands(command) + if subcommands is not None: + index_dict = {1: subcommands} + compfunc = functools.partial(index_based_complete, + index_dict=index_dict, + default_completer=compfunc) + + # Call the completer function + self.completion_matches = compfunc(text, line, begidx, endidx) + else: - compfunc = self.completenames + # Complete the command against command names and shortcuts. By design, shortcuts that start with + # symbols not in self.identchars won't be tab completed since they are handled in the above if + # statement. This includes shortcuts like: ?, !, @, @@ + strs_to_match = [] + + # If a command has been started, then match against shortcuts. This keeps shortcuts out of the + # full list of commands that show up when tab completion is done on an empty line. + if len(line) > 0: + for (shortcut, expansion) in self.shortcuts: + strs_to_match.append(shortcut) - self.completion_matches = compfunc(text, line, begidx, endidx) + # Get command names + do_text = 'do_' + text + strs_to_match.extend([cur_name[3:] for cur_name in self.get_names() if cur_name.startswith(do_text)]) + + # Perform matching + completions = [cur_str for cur_str in strs_to_match 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] += ' ' + + self.completion_matches = completions try: return self.completion_matches[state] except IndexError: return None + def complete_help(self, text, line, begidx, endidx): + """ + Override of parent class method to handle tab completing subcommands + """ + + # Get all tokens prior to text 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 [] + + completions = [] + + # If we have "help" and a completed command token, then attempt to match subcommands + if len(tokens) == 2: + + # 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)] + + # Run normal help completion from the parent class + else: + completions = cmd.Cmd.complete_help(self, text, line, begidx, endidx) + + # 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] += ' ' + + completions.sort() + return completions + def precmd(self, statement): """Hook method executed just before the command is processed by ``onecmd()`` and after adding it to the history. @@ -1200,13 +1466,25 @@ class Cmd(cmd.Cmd): # Expand command shortcuts to the full command name for (shortcut, expansion) in self.shortcuts: if line.startswith(shortcut): - line = line.replace(shortcut, expansion + ' ', 1) + # If the next character after the shortcut isn't a space, then insert one + shortcut_len = len(shortcut) + if len(line) == shortcut_len or line[shortcut_len] != ' ': + expansion += ' ' + + # Expand the shortcut + line = line.replace(shortcut, expansion, 1) break i, n = 0, len(line) while i < n and line[i] in self.identchars: i += 1 command, arg = line[:i], line[i:].strip() + + # Make sure there is a space between the command and args + # This can occur when a character not in self.identchars bumps against the command (ex: help@) + if len(command) > 0 and len(arg) > 0 and line[len(command)] != ' ': + line = line.replace(command, command + ' ', 1) + return command, arg, line def onecmd_plus_hooks(self, line): @@ -1771,96 +2049,6 @@ class Cmd(cmd.Cmd): proc = subprocess.Popen(command, stdout=self.stdout, shell=True) proc.communicate() - def path_complete(self, 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 indexe 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 - """ - # Deal with cases like load command and @ key when path completion is immediately after a shortcut - for (shortcut, expansion) in self.shortcuts: - if line.startswith(shortcut): - # If the next character after the shortcut isn't a space, then insert one and adjust indices - shortcut_len = len(shortcut) - if len(line) == shortcut_len or line[shortcut_len] != ' ': - line = line.replace(shortcut, shortcut + ' ', 1) - begidx += 1 - endidx += 1 - break - - # 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 - - add_sep_after_tilde = False - # If no path and no search text has been entered, then search in the CWD for * - if not text and line[begidx - 1] == ' ' and (begidx >= len(line) or line[begidx] == ' '): - search_str = os.path.join(os.getcwd(), '*') - else: - # Parse out the path being searched - prev_space_index = line.rfind(' ', 0, begidx) - dirname = line[prev_space_index + 1:begidx] - - # 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 tilde was used without separator, add a separator after the tilde in the completions - add_sep_after_tilde = 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 - 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 for convenience - if os.path.isfile(path_completions[0]) and endidx == len(line): - completions[0] += ' ' - # If tilde was expanded without a separator, prepend one - elif os.path.isdir(path_completions[0]) and add_sep_after_tilde: - completions[0] = os.path.sep + completions[0] - - # If there are multiple completions, then sort them alphabetically - return sorted(completions) - - # Enable tab completion of paths for relevant commands - complete_edit = path_complete - complete_load = path_complete - # noinspection PyUnusedLocal @staticmethod def _shell_command_complete(text, line, begidx, endidx): @@ -1882,23 +2070,27 @@ class Cmd(cmd.Cmd): # 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() + # Find every executable file in the PATH that matches the pattern - exes = [] for path in paths: full_path = os.path.join(path, text) 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.append(os.path.basename(match)) + exes.add(os.path.basename(match)) + + # Sort the exes alphabetically + results = list(exes) + results.sort() # If there is a single completion and we are at end of the line, then add a space at the end for convenience - if len(exes) == 1 and endidx == len(line): - exes[0] += ' ' + if len(results) == 1 and endidx == len(line): + results[0] += ' ' - # If there are multiple completions, then sort them alphabetically - return sorted(exes) + return results - # noinspection PyUnusedLocal def complete_shell(self, text, line, begidx, endidx): """Handles tab completion of executable commands and local file system paths. @@ -1909,49 +2101,44 @@ class Cmd(cmd.Cmd): :return: List[str] - a list of possible tab completions """ - # First we strip off the shell command or shortcut key - if line.startswith('!'): - stripped_line = line.lstrip('!') - initial_length = len('!') - else: - stripped_line = line[len('shell'):] - initial_length = len('shell') - - line_parts = stripped_line.split() - - # Don't tab complete anything if user only typed shell or ! - if not line_parts: + # Get all tokens prior to text 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 [] - # Find the start index of the first thing after the shell or ! - cmd_start = line.find(line_parts[0], initial_length) - cmd_end = cmd_start + len(line_parts[0]) + # Nothing to do + if len(tokens) == 0: + return [] - # Check if we are in the command token - if cmd_start <= begidx <= cmd_end: + # Check if we are still completing the shell command + elif len(tokens) == 1: - # See if text is part of a path - possible_path = line[cmd_start:begidx] + # 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] - # There is nothing to search - if len(possible_path) == 0 and not text: + # Don't tab complete anything if no shell command has been started + if len(cmd_token) == 0: return [] - if os.path.sep not in possible_path and possible_path != '~': - # The text before the search text is not a directory path. - # It is OK to try shell command completion. + 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._shell_command_complete(text, line, begidx, endidx) if command_completions: return command_completions - # If we have no results, try path completion - return self.path_complete(text, line, begidx, endidx, dir_exe_only=True) + # If we have no results, try path completion to find the shell commands + return path_complete(text, line, begidx, endidx, dir_exe_only=True) - # Past command token + # Shell command has been completed else: # Do path completion - return self.path_complete(text, line, begidx, endidx) + return path_complete(text, line, begidx, endidx) # noinspection PyBroadException def do_py(self, arg): @@ -2059,8 +2246,8 @@ Paths or arguments that contain spaces must be enclosed in quotes # Restore command line arguments to original state sys.argv = orig_args - # Enable tab completion of paths for pyscript command - complete_pyscript = path_complete + # Enable tab-completion for pyscript command + complete_pyscript = functools.partial(path_complete) # Only include the do_ipy() method if IPython is available on the system if ipython_available: @@ -2201,6 +2388,9 @@ The editor used is determined by the ``editor`` settable parameter. else: os.system('"{}"'.format(self.editor)) + # Enable tab-completion for edit command + complete_edit = functools.partial(path_complete) + @property def _current_script_dir(self): """Accessor to get the current script directory from the _script_dir LIFO queue.""" @@ -2287,6 +2477,9 @@ Script should contain one command per line, just like command would be typed in self._script_dir.append(os.path.dirname(expanded_path)) + # Enable tab-completion for load command + complete_load = functools.partial(path_complete) + @staticmethod def is_text_file(file_path): """ diff --git a/examples/python_scripting.py b/examples/python_scripting.py index aa62007a..f4606251 100755 --- a/examples/python_scripting.py +++ b/examples/python_scripting.py @@ -18,15 +18,15 @@ import argparse import functools import os -from cmd2 import Cmd, CmdResult, with_argument_list, with_argparser_and_unknown_args +import cmd2 -class CmdLineApp(Cmd): +class CmdLineApp(cmd2.Cmd): """ Example cmd2 application to showcase conditional control flow in Python scripting within cmd2 aps. """ def __init__(self): # Enable the optional ipy command if IPython is installed by setting use_ipython=True - Cmd.__init__(self, use_ipython=True) + cmd2.Cmd.__init__(self, use_ipython=True) self._set_prompt() self.intro = 'Happy 𝛑 Day. Note the full Unicode support: 😇 (Python 3 only) 💩' @@ -46,7 +46,7 @@ class CmdLineApp(Cmd): self._set_prompt() return stop - @with_argument_list + @cmd2.with_argument_list def do_cd(self, arglist): """Change directory. Usage: @@ -56,7 +56,7 @@ class CmdLineApp(Cmd): if not arglist or len(arglist) != 1: self.perror("cd requires exactly 1 argument:", traceback_war=False) self.do_help('cd') - self._last_result = CmdResult('', 'Bad arguments') + self._last_result = cmd2.CmdResult('', 'Bad arguments') return # Convert relative paths to absolute paths @@ -80,22 +80,22 @@ class CmdLineApp(Cmd): if err: self.perror(err, traceback_war=False) - self._last_result = CmdResult(out, err) + self._last_result = cmd2.CmdResult(out, err) - # Enable directory completion for cd command by freezing an argument to path_complete() with functools.partialmethod - complete_cd = functools.partialmethod(Cmd.path_complete, dir_only=True) + # 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) dir_parser = argparse.ArgumentParser() dir_parser.add_argument('-l', '--long', action='store_true', help="display in long format with one item per line") - @with_argparser_and_unknown_args(dir_parser) + @cmd2.with_argparser_and_unknown_args(dir_parser) def do_dir(self, args, unknown): """List contents of current directory.""" # No arguments for this command if unknown: self.perror("dir does not take any positional arguments:", traceback_war=False) self.do_help('dir') - self._last_result = CmdResult('', 'Bad arguments') + self._last_result = cmd2.CmdResult('', 'Bad arguments') return # Get the contents as a list @@ -108,7 +108,7 @@ class CmdLineApp(Cmd): self.stdout.write(fmt.format(f)) self.stdout.write('\n') - self._last_result = CmdResult(contents) + self._last_result = cmd2.CmdResult(contents) if __name__ == '__main__': diff --git a/examples/subcommands.py b/examples/subcommands.py index e77abc61..a278fd8b 100755 --- a/examples/subcommands.py +++ b/examples/subcommands.py @@ -24,7 +24,7 @@ class SubcommandsExample(cmd2.Cmd): 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) # create the top-level parser for the base command diff --git a/examples/tab_completion.py b/examples/tab_completion.py new file mode 100755 index 00000000..6c16e63b --- /dev/null +++ b/examples/tab_completion.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python +# coding=utf-8 +"""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 + +# 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.""" + + def __init__(self): + cmd2.Cmd.__init__(self) + + add_item_parser = argparse.ArgumentParser() + add_item_group = add_item_parser.add_mutually_exclusive_group() + add_item_group.add_argument('-f', '--food', help='Adds food item') + add_item_group.add_argument('-s', '--sport', help='Adds sport item') + add_item_group.add_argument('-o', '--other', help='Adds other item') + + @with_argparser(add_item_parser) + def do_add_item(self, args): + """Add item command help""" + if args.food: + add_item = args.food + elif args.sport: + add_item = args.sport + elif args.other: + add_item = args.other + else: + add_item = 'no items' + + 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) + + @with_argument_list + def do_list_item(self, args): + """List item command help""" + 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) + + +if __name__ == '__main__': + app = TabCompleteExample() + app.cmdloop() diff --git a/tests/test_completion.py b/tests/test_completion.py index 0ae4215f..5bdbf457 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -17,6 +17,7 @@ import cmd2 import mock import pytest +from cmd2 import path_complete, flag_based_complete, index_based_complete @pytest.fixture def cmd2_app(): @@ -58,8 +59,9 @@ def test_complete_command_single_end(cmd2_app): 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 - completion = cmd2_app.complete(text, state) - assert completion == 'help ' + first_match = cmd2_app.complete(text, state) + + assert first_match is not None and cmd2_app.completion_matches == ['help '] def test_complete_command_invalid_state(cmd2_app): text = 'he' @@ -81,8 +83,9 @@ def test_complete_command_invalid_state(cmd2_app): 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 - completion = cmd2_app.complete(text, state) - assert completion is None + first_match = cmd2_app.complete(text, state) + + assert first_match is None def test_complete_empty_arg(cmd2_app): text = '' @@ -104,9 +107,10 @@ def test_complete_empty_arg(cmd2_app): 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 - completion = cmd2_app.complete(text, state) + first_match = cmd2_app.complete(text, state) - assert completion == cmd2_app.complete_help(text, line, begidx, endidx)[0] + assert first_match is not None and \ + cmd2_app.completion_matches == cmd2_app.complete_help(text, line, begidx, endidx) def test_complete_bogus_command(cmd2_app): text = '' @@ -128,9 +132,9 @@ def test_complete_bogus_command(cmd2_app): 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 - completion = cmd2_app.complete(text, state) + first_match = cmd2_app.complete(text, state) - assert completion is None + assert first_match is None def test_cmd2_command_completion_is_case_insensitive_by_default(cmd2_app): text = 'HE' @@ -177,28 +181,28 @@ def test_cmd2_help_completion_single_end(cmd2_app): 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.completenames(text, line, begidx, endidx) == ['help'] + 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.completenames(text, line, begidx, endidx) == ['help'] + assert cmd2_app.complete_help(text, line, begidx, endidx) == ['help'] def test_cmd2_help_completion_multiple(cmd2_app): text = 'h' line = 'help h' endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.completenames(text, line, begidx, endidx) == ['help', 'history'] + assert cmd2_app.complete_help(text, line, begidx, endidx) == ['help', 'history'] def test_cmd2_help_completion_nomatch(cmd2_app): text = 'z' line = 'help z' endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.completenames(text, line, begidx, endidx) == [] + assert cmd2_app.complete_help(text, line, begidx, endidx) == [] def test_shell_command_completion(cmd2_app): if sys.platform == "win32": @@ -207,7 +211,7 @@ def test_shell_command_completion(cmd2_app): expected = ['calc.exe '] else: text = 'egr' - line = '!{}'.format(text) + line = 'shell {}'.format(text) expected = ['egrep '] endidx = len(line) @@ -220,7 +224,7 @@ def test_shell_command_completion_doesnt_match_wildcards(cmd2_app): line = 'shell {}'.format(text) else: text = 'e*' - line = '!{}'.format(text) + line = 'shell {}'.format(text) endidx = len(line) begidx = endidx - len(text) @@ -233,7 +237,7 @@ def test_shell_command_completion_multiple(cmd2_app): expected = 'calc.exe' else: text = 'l' - line = '!{}'.format(text) + line = 'shell {}'.format(text) expected = 'ls' endidx = len(line) @@ -249,10 +253,7 @@ def test_shell_command_completion_nomatch(cmd2_app): def test_shell_command_completion_doesnt_complete_when_just_shell(cmd2_app): text = '' - if sys.platform == "win32": - line = 'shell'.format(text) - else: - line = '!'.format(text) + line = 'shell' endidx = len(line) begidx = endidx - len(text) @@ -271,121 +272,269 @@ def test_shell_command_completion_does_path_completion_when_after_command(cmd2_a assert cmd2_app.complete_shell(text, line, begidx, endidx) == ['conftest.py '] -def test_path_completion_single_end(cmd2_app, request): +def test_path_completion_single_end(request): test_dir = os.path.dirname(request.module.__file__) text = 'c' path = os.path.join(test_dir, text) - line = '!cat {}'.format(path) + line = 'shell cat {}'.format(path) endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.path_complete(text, line, begidx, endidx) == ['conftest.py '] + assert path_complete(text, line, begidx, endidx) == ['conftest.py '] -def test_path_completion_single_mid(cmd2_app, request): +def test_path_completion_single_mid(request): test_dir = os.path.dirname(request.module.__file__) text = 'tes' path = os.path.join(test_dir, 'c') - line = '!cat {}'.format(path) + line = 'shell cat {}'.format(path) begidx = line.find(text) endidx = begidx + len(text) - assert cmd2_app.path_complete(text, line, begidx, endidx) == ['tests' + os.path.sep] + assert path_complete(text, line, begidx, endidx) == ['tests' + os.path.sep] -def test_path_completion_multiple(cmd2_app, request): +def test_path_completion_multiple(request): test_dir = os.path.dirname(request.module.__file__) text = 's' path = os.path.join(test_dir, text) - line = '!cat {}'.format(path) + line = 'shell cat {}'.format(path) endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.path_complete(text, line, begidx, endidx) == ['script.py', 'script.txt', 'scripts' + os.path.sep] + assert path_complete(text, line, begidx, endidx) == ['script.py', 'script.txt', 'scripts' + os.path.sep] -def test_path_completion_nomatch(cmd2_app, request): +def test_path_completion_nomatch(request): test_dir = os.path.dirname(request.module.__file__) text = 'z' path = os.path.join(test_dir, text) - line = '!cat {}'.format(path) + line = 'shell cat {}'.format(path) endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.path_complete(text, line, begidx, endidx) == [] + assert path_complete(text, line, begidx, endidx) == [] -def test_path_completion_cwd(cmd2_app): +def test_path_completion_cwd(): # Run path complete with no path and no search text text = '' - line = '!ls {}'.format(text) + line = 'shell ls {}'.format(text) endidx = len(line) begidx = endidx - len(text) - completions_empty = cmd2_app.path_complete(text, line, begidx, endidx) + completions_empty = path_complete(text, line, begidx, endidx) # Run path complete with path set to the CWD cwd = os.getcwd() - line = '!ls {}'.format(cwd) + line = 'shell ls {}'.format(cwd) endidx = len(line) begidx = endidx - len(text) - completions_cwd = cmd2_app.path_complete(text, line, begidx, endidx) + 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 assert completions_cwd -def test_path_completion_doesnt_match_wildcards(cmd2_app, request): +def test_path_completion_doesnt_match_wildcards(request): test_dir = os.path.dirname(request.module.__file__) text = 'c*' path = os.path.join(test_dir, text) - line = '!cat {}'.format(path) + line = 'shell cat {}'.format(path) endidx = len(line) begidx = endidx - len(text) # Currently path completion doesn't accept wildcards, so will always return empty results - assert cmd2_app.path_complete(text, line, begidx, endidx) == [] + assert path_complete(text, line, begidx, endidx) == [] -def test_path_completion_user_expansion(cmd2_app): +def test_path_completion_user_expansion(): # Run path with just a tilde text = '' if sys.platform.startswith('win'): - line = '!dir ~\{}'.format(text) + line = 'shell dir ~{}'.format(text) else: - line = '!ls ~/{}'.format(text) + line = 'shell ls ~{}'.format(text) endidx = len(line) begidx = endidx - len(text) - completions_tilde = cmd2_app.path_complete(text, line, begidx, endidx) + completions_tilde = path_complete(text, line, begidx, endidx) # Run path complete on the user's home directory user_dir = os.path.expanduser('~') if sys.platform.startswith('win'): - line = '!dir {}'.format(user_dir) + line = 'shell dir {}'.format(user_dir) else: - line = '!ls {}'.format(user_dir) + line = 'shell ls {}'.format(user_dir) endidx = len(line) begidx = endidx - len(text) - completions_home = cmd2_app.path_complete(text, line, begidx, endidx) + completions_home = path_complete(text, line, begidx, endidx) # Verify that the results are the same in both cases assert completions_tilde == completions_home -def test_path_completion_directories_only(cmd2_app, request): +def test_path_completion_directories_only(request): test_dir = os.path.dirname(request.module.__file__) text = 's' path = os.path.join(test_dir, text) - line = '!cat {}'.format(path) + line = 'shell cat {}'.format(path) + + endidx = len(line) + begidx = endidx - len(text) + + assert path_complete(text, line, begidx, endidx, dir_only=True) == ['scripts' + os.path.sep] + + +# 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 + } + +def test_flag_based_completion_single_end(): + text = 'Pi' + line = 'list_food -f Pi' + 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'] + +def test_flag_based_completion_multiple(): + text = '' + line = 'list_food -f ' + endidx = len(line) + begidx = endidx - len(text) + assert flag_based_complete(text, line, begidx, endidx, flag_dict) == sorted(food_item_strs) + +def test_flag_based_completion_nomatch(): + text = 'q' + line = 'list_food -f q' + endidx = len(line) + begidx = endidx - len(text) + assert flag_based_complete(text, line, begidx, endidx, flag_dict) == [] + +def test_flag_based_default_completer(request): + test_dir = os.path.dirname(request.module.__file__) + + text = 'c' + path = os.path.join(test_dir, text) + line = 'list_food {}'.format(path) + + endidx = len(line) + begidx = endidx - len(text) + + assert flag_based_complete(text, line, begidx, endidx, flag_dict, path_complete) == ['conftest.py '] + +def test_flag_based_callable_completer(request): + test_dir = os.path.dirname(request.module.__file__) + + text = 'c' + path = os.path.join(test_dir, text) + line = 'list_food -o {}'.format(path) + + endidx = len(line) + begidx = endidx - len(text) + + assert flag_based_complete(text, line, begidx, endidx, flag_dict, path_complete) == ['conftest.py '] + +def test_flag_based_completion_syntax_err(): + text = 'Pi' + line = 'list_food -f " Pi' + endidx = len(line) + begidx = endidx - len(text) + + assert flag_based_complete(text, line, begidx, endidx, flag_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_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 '] + +def test_index_based_completion_single_mid(): + text = 'Foo' + line = 'command Pizza Foo' + begidx = len(line) - len(text) + endidx = begidx + 1 + + assert index_based_complete(text, line, begidx, endidx, index_dict) == ['Football'] + +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) - assert cmd2_app.path_complete(text, line, begidx, endidx, dir_only=True) == ['scripts' + os.path.sep] +def test_index_based_completion_nomatch(): + text = 'q' + line = 'command q' + endidx = len(line) + begidx = endidx - len(text) + assert index_based_complete(text, line, begidx, endidx, index_dict) == [] + +def test_index_based_default_completer(request): + test_dir = os.path.dirname(request.module.__file__) + + text = 'c' + path = os.path.join(test_dir, text) + line = 'command Pizza Bat Computer {}'.format(path) + + endidx = len(line) + begidx = endidx - len(text) + + assert index_based_complete(text, line, begidx, endidx, index_dict, path_complete) == ['conftest.py '] + +def test_index_based_callable_completer(request): + test_dir = os.path.dirname(request.module.__file__) + + text = 'c' + path = os.path.join(test_dir, text) + line = 'command Pizza Bat {}'.format(path) + + endidx = len(line) + begidx = endidx - len(text) + + assert index_based_complete(text, line, begidx, endidx, index_dict) == ['conftest.py '] + +def test_index_based_completion_syntax_err(): + text = 'Foo' + line = 'command "Pizza Foo' + endidx = len(line) + begidx = endidx - len(text) + + assert index_based_complete(text, line, begidx, endidx, index_dict) == [] def test_parseline_command_and_args(cmd2_app): @@ -398,9 +547,9 @@ def test_parseline_command_and_args(cmd2_app): def test_parseline_emptyline(cmd2_app): line = '' command, args, out_line = cmd2_app.parseline(line) - assert command == None - assert args == None - assert line == out_line + assert command is None + assert args is None + assert line is out_line def test_parseline_strips_line(cmd2_app): line = ' help history ' @@ -429,7 +578,7 @@ class SubcommandsExample(cmd2.Cmd): 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) # create the top-level parser for the base command @@ -472,46 +621,121 @@ def test_cmd2_subcommand_completion_single_end(sc_app): line = 'base f' 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) # It is at end of line, so extra space is present - assert sc_app.complete_subcommand(text, line, begidx, endidx) == ['foo '] + 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 f' + line = 'base fo' endidx = len(line) - 1 begidx = endidx - len(text) + state = 0 - # It is at end of line, so extra space is present - assert sc_app.complete_subcommand(text, line, begidx, endidx) == ['foo'] + 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 ' endidx = len(line) begidx = endidx - len(text) + state = 0 - # It is at end of line, so extra space is present - assert sc_app.complete_subcommand(text, line, begidx, endidx) == ['foo', 'bar'] + 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'] def test_cmd2_subcommand_completion_nomatch(sc_app): text = 'z' line = 'base z' endidx = len(line) begidx = endidx - len(text) + state = 0 - # It is at end of line, so extra space is present - assert sc_app.complete_subcommand(text, line, begidx, endidx) == [] + 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 None def test_cmd2_subcommand_completion_after_subcommand(sc_app): text = 'f' line = 'base foo f' endidx = len(line) begidx = endidx - len(text) + state = 0 - # It is at end of line, so extra space is present - assert sc_app.complete_subcommand(text, line, begidx, endidx) == [] + 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 None def test_complete_subcommand_single_end(sc_app): text = 'f' @@ -533,5 +757,37 @@ def test_complete_subcommand_single_end(sc_app): 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 - completion = sc_app.complete(text, state) - assert completion == 'foo ' + first_match = sc_app.complete(text, state) + + assert first_match is not None and sc_app.completion_matches == ['foo '] + + +def test_cmd2_help_subcommand_completion_single_end(sc_app): + text = 'base' + line = 'help base' + 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 '] + +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_cmd2_help_subcommand_completion_multiple(sc_app): + text = '' + line = 'help base ' + endidx = len(line) + begidx = endidx - len(text) + assert sc_app.complete_help(text, line, begidx, endidx) == ['bar', 'foo'] + +def test_cmd2_help_subcommand_completion_nomatch(sc_app): + text = 'z' + line = 'help base z' + endidx = len(line) + begidx = endidx - len(text) + assert sc_app.complete_help(text, line, begidx, endidx) == [] |