diff options
Diffstat (limited to 'cmd2.py')
-rwxr-xr-x | cmd2.py | 543 |
1 files changed, 368 insertions, 175 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): """ |