diff options
Diffstat (limited to 'cmd2.py')
-rwxr-xr-x | cmd2.py | 1257 |
1 files changed, 924 insertions, 333 deletions
@@ -27,6 +27,7 @@ import atexit import cmd import codecs import collections +import copy import datetime import functools import glob @@ -47,6 +48,33 @@ from code import InteractiveConsole 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,94 +128,306 @@ except ImportError: except ImportError: pass -# BrokenPipeError and FileNotFoundError exist only in Python 3. Use IOError for Python 2. -if six.PY3: - BROKEN_PIPE_ERROR = BrokenPipeError - FILE_NOT_FOUND_ERROR = FileNotFoundError +# Load the GNU readline lib so we can make changes to it +readline_lib = None +if not sys.platform.startswith('win'): + import ctypes + from ctypes.util import find_library + + readline_lib_name = find_library("readline") + if readline_lib_name is not None and readline_lib_name: + readline_lib = ctypes.CDLL(readline_lib_name) + +# On Windows, we save the original pyreadline display completion function since we have to override it else: - BROKEN_PIPE_ERROR = FILE_NOT_FOUND_ERROR = IOError + # noinspection PyProtectedMember + orig_pyreadline_display = readline.rl.mode._display_completions + +############################################################################################################ +# 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. +# For convenience, use the setter functions for these variables. The setters appear after the variables. +############################################################################################################ + +# If true and a single match is returned to complete(), then a space will be appended +# if the match appears at the end of the line +allow_appended_space = True + +# If true and a single match is returned to complete(), then a closing quote +# will be added if there is an umatched opening quote +allow_closing_quote = True + +########################################################################################################### +# display_entire_match +# If this is True, then the tab completion suggestions will be the entire token that was matched. +# If False, then this works like path matching where only the portion of the completion being matched +# is shown as tab completion suggestions. See the documentation for display_match_delimiter below +# to use a delimiter other than a path slash to determine what portion of the completion to display. +# +# Note: path_complete() and shell_cmd_complete() always behave as if this flag is False +# +# display_match_delimiter +# This delimiter can be used to separate matches with something other than a path slash. For instance, +# if you are matching against strings formatted like name::address::zip, you could set the delimiter to '::'. +# That way, the tab completion suggestions will only display the part of that string being completed instead +# of showing the entire string for each completion suggestions. + +# Note: Defaults to os.path.sep (OS specific path slash) +# Note: Only applies when display_entire_match is False +########################################################################################################### +display_entire_match = True +display_match_delimiter = os.path.sep + +# 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. +matches_to_display = None + + +# Use these functions to set the readline variables +def set_allow_appended_space(allow): + """ + Sets allow_appended_space flag + :param allow: bool - the new value for allow_appended_space + """ + global allow_appended_space + allow_appended_space = allow -# On some systems, pyperclip will import gtk for its clipboard functionality. -# The following code is a workaround for gtk interfering with printing from a background -# thread while the CLI thread is blocking in raw_input() in Python 2 on Linux. -if six.PY2 and sys.platform.startswith('lin'): - try: - # noinspection PyUnresolvedReferences - import gtk - gtk.set_interactive(0) - except ImportError: - pass -__version__ = '0.8.2' +def set_allow_closing_quote(allow): + """ + Sets allow_closing_quote flag + :param allow: bool - the new value for allow_closing_quote + """ + global allow_closing_quote + allow_closing_quote = allow -# Pyparsing enablePackrat() can greatly speed up parsing, but problems have been seen in Python 3 in the past -pyparsing.ParserElement.enablePackrat() -# Override the default whitespace chars in Pyparsing so that newlines are not treated as whitespace -pyparsing.ParserElement.setDefaultWhitespaceChars(' \t') +def set_display_entire_match(entire, delim=os.path.sep): + """ + Sets display_entire_match flag + :param entire: bool - the new value for display_entire_match + :param delim: str - the new value for display_match_delimiter + """ + global display_entire_match + global display_match_delimiter + display_entire_match = entire + display_match_delimiter = delim -# 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 -# 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" -# Use POSIX or Non-POSIX (Windows) rules for splitting a command-line string into a list of arguments via shlex.split() -POSIX_SHLEX = False +def set_matches_to_display(matches): + """ + Sets the tab-completion matches that will be displayed to the screen + :param matches: the matches to display + """ + global matches_to_display + matches_to_display = matches -# Strip outer quotes for convenience if POSIX_SHLEX = False -STRIP_QUOTES_FOR_NON_POSIX = True -# For @options commands, pass a list of argument strings instead of a single argument string to the do_* methods -USE_ARG_LIST = True +def set_completion_defaults(): + """ + Resets tab completion settings + Called each time complete() is called + """ + set_allow_appended_space(True) + set_allow_closing_quote(True) + set_display_entire_match(True) + set_matches_to_display(None) + if readline_lib is not None: + # Set GNU readline's rl_basic_quote_characters to NULL so it won't automatically add a closing quote + rl_basic_quote_characters = ctypes.c_char_p.in_dll(readline_lib, "rl_basic_quote_characters") + rl_basic_quote_characters.value = None -def set_posix_shlex(val): - """ Allows user of cmd2 to choose between POSIX and non-POSIX splitting of args for decorated commands. + # Set GNU readline's rl_completion_suppress_quote to 1 so it won't automatically add a closing quote + suppress_quote = ctypes.c_int.in_dll(readline_lib, "rl_completion_suppress_quote") + suppress_quote.value = 1 - :param val: bool - True => POSIX, False => Non-POSIX + +def display_match_list_gnu_readline(substitution, matches, longest_match_length): """ - global POSIX_SHLEX - POSIX_SHLEX = val + 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 readline_lib is not None: + # 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))() -def set_strip_quotes(val): - """ Allows user of cmd2 to choose whether to automatically strip outer-quotes when POSIX_SHLEX is False. + # 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 - :param val: bool - True => strip quotes on args for decorated commands if POSIX_SHLEX is False. + # 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 + + +def display_match_list_pyreadline(matches): """ - global STRIP_QUOTES_FOR_NON_POSIX - STRIP_QUOTES_FOR_NON_POSIX = val + Prints a match list using pyreadline's _display_completions() + :param matches: list[str] - the tab completion matches to display + """ + if orig_pyreadline_display is not None: + orig_pyreadline_display(matches) -def set_use_arg_list(val): - """ Allows user of cmd2 to choose between passing @options commands an argument string or list of arg strings. +############################################################################################################ +# The following functions are tab-completion routines that can be imported from this module +# and have no dependence on a cmd2 instance. +############################################################################################################ - :param val: bool - True => arg is a list of strings, False => arg is a string (for @options commands) +QUOTES = ['"', "'"] + + +def tokens_for_completion(line, begidx, endidx, preserve_quotes=False): """ - global USE_ARG_LIST - USE_ARG_LIST = val + Used by tab completion functions to get all tokens through the one being completed + This also handles tab-completion within quotes + :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 preserve_quotes - if True, then the tokens will be returned still wrapped in whatever quotes + appeared on the command line. This defaults to False since tab-completion routines + generally need the tokens unquoted. + :return: If successful parsing occurs, then a non-empty list of tokens is returned where the last token + in the list is the one being tab completed. + None is returned if parsing fails due to a malformed line. + """ + unclosed_quote = '' + quotes_to_try = copy.copy(QUOTES) + + tmp_line = line[:endidx] + tmp_endidx = endidx + + while True: + try: + # Use non-POSIX parsing to keep the quotes around the tokens. + tokens = shlex.split(tmp_line[:tmp_endidx], posix=False) + 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 + + # 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_endidx = endidx + 1 + tmp_line += unclosed_quote + + # Check if the cursor is at the end of the line and not within a quoted string + if begidx == endidx and not unclosed_quote: + + # If the line is blank or the cursor is preceded by a space, then the actual + # token being completed is blank. Add this to the token list. + prev_space_index = max(line.rfind(' ', 0, begidx), 0) + if prev_space_index == 0 or prev_space_index == begidx - 1: + tokens.append('') + + if preserve_quotes: + # If the token being completed had an unclosed quote, + # we need remove the closing quote that was added + if unclosed_quote: + tokens[-1] = tokens[-1][:-1] + + # Unquote all tokens + else: + for index, cur_token in enumerate(tokens): + tokens[index] = strip_quotes(cur_token) + + return tokens -# noinspection PyUnusedLocal def basic_complete(text, line, begidx, endidx, match_against): """ Performs tab completion against a list + This is ultimately called by many completer functions like flag_based_complete and index_based_complete. + It can also be used by custom completer functions and that is the suggested approach since this function + handles things like tab completions with spaces as well as the display_entire_match flag. + :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 + :param match_against: Collection - the list being matched against + :return: List[str] - a sorted list of possible tab completions """ - completions = [cur_str for cur_str in match_against if cur_str.startswith(text)] + # Make sure we were given an Collection with items to match against + if not isinstance(match_against, Collection) or len(match_against) == 0: + return [] - # 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] += ' ' + # Get all tokens through the one being completed + tokens = tokens_for_completion(line, begidx, endidx) + if tokens is None: + return [] + + # Perform matching and eliminate duplicates + completion_token = tokens[-1] + full_matches = [cur_match for cur_match in set(match_against) if cur_match.startswith(completion_token)] + if len(full_matches) == 0: + return [] + + # We will only keep where the text value starts + starting_index = len(completion_token) - len(text) + completion_matches = [cur_match[starting_index:] for cur_match in full_matches] + + # The tab-completion suggestions that will be displayed + display_matches = [] + + # Tab-completion suggestions will show the entire match + if display_entire_match: + display_matches = full_matches - completions.sort() - return completions + # Display only the portion of the match that's still being completed based on delimiter + elif len(full_matches) > 0: + # Get the common beginning for the matches + common_prefix = os.path.commonprefix(full_matches) + prefix_tokens = common_prefix.split(display_match_delimiter) + + display_token_index = 0 + if len(prefix_tokens) > 0: + display_token_index = len(prefix_tokens) - 1 + + # Build the display match list + for cur_match in full_matches: + match_tokens = cur_match.split(display_match_delimiter) + display_token = match_tokens[display_token_index] + if len(display_token) == 0: + display_token = display_match_delimiter + display_matches.append(display_token) + + # Set what matches will display + set_matches_to_display(display_matches) + + completion_matches.sort() + return completion_matches def flag_based_complete(text, line, begidx, endidx, flag_dict, all_else=None): @@ -203,45 +443,35 @@ def flag_based_complete(text, line, begidx, endidx, flag_dict, all_else=None): 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 + :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 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: + # Get all tokens through the one being completed + tokens = tokens_for_completion(line, begidx, endidx) + if tokens is None: return [] - completions = [] + completions_matches = [] match_against = all_else - # Must have at least the command and one argument for a flag to be present + # Must have at least 2 args for a flag to precede the token being completed if len(tokens) > 1: - flag = tokens[-1] + flag = tokens[-2] 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 an Collection. These matches are already sorted. + if isinstance(match_against, Collection): + completions_matches = basic_complete(text, line, begidx, endidx, match_against) # Perform tab completion using a function elif callable(match_against): - completions = match_against(text, line, begidx, endidx) + completions_matches = match_against(text, line, begidx, endidx) + completions_matches.sort() - completions.sort() - return completions + return completions_matches def index_based_complete(text, line, begidx, endidx, index_dict, all_else=None): @@ -257,26 +487,20 @@ def index_based_complete(text, line, begidx, endidx, index_dict, all_else=None): 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 + :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 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 = tokens_for_completion(line, begidx, endidx) + if tokens is None: return [] - if len(tokens) == 0: - return [] - - completions = [] + completion_matches = [] # Get the index of the token being completed - index = len(tokens) + index = len(tokens) - 1 # Check if token is at an index in the dictionary if index in index_dict: @@ -284,24 +508,20 @@ def index_based_complete(text, line, begidx, endidx, index_dict, all_else=None): 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 an Collection. These matches are already sorted. + if isinstance(match_against, Collection): + completion_matches = basic_complete(text, line, begidx, endidx, match_against) # Perform tab completion using a function elif callable(match_against): - completions = match_against(text, line, begidx, endidx) + completion_matches = match_against(text, line, begidx, endidx) + completion_matches.sort() - completions.sort() - return completions + return completion_matches 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. + """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 @@ -309,18 +529,12 @@ def path_complete(text, line, begidx, endidx, dir_exe_only=False, dir_only=False :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 + :return: List[str] - 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) - return [] - - if len(tokens) == 0: + # Get all tokens through the one being completed + tokens = tokens_for_completion(line, begidx, endidx) + if tokens is None: return [] # Determine if a trailing separator should be appended to directory completions @@ -328,64 +542,252 @@ def path_complete(text, line, begidx, endidx, dir_exe_only=False, dir_only=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] + completion_token = tokens[-1] + + # Used to replace cwd in the final results + cwd = os.getcwd() + cwd_added = False - # If no directory path and no search text has been entered, then search in the CWD for * - if not dirname and not text: + # Used to replace ~ in the final results + user_path = os.path.expanduser('~') + tilde_expanded = False + + # If the token being completed is blank, then search in the CWD for * + if not completion_token: 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 dirname or wildcard in text: + if wildcard in completion_token: return [] - if not dirname: - dirname = os.getcwd() - elif dirname == '~': - # If a ~ was used without a separator between text, then this is invalid - if text: + # 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 completion_token == '~': + # This is a directory, so don't add a space or quote + set_allow_appended_space(False) + set_allow_closing_quote(False) + return [completion_token + os.path.sep] + + elif completion_token.startswith('~'): + # Tilde without separator between path is invalid + if not completion_token.startswith('~' + os.path.sep): return [] - # If only a ~ was entered, then complete it with a slash - else: - return [os.path.sep] + + # Mark that we are expanding a tilde + tilde_expanded = True + + # If the token does not have a directory, then use the cwd + elif not os.path.dirname(completion_token): + dirname = os.getcwd() + cwd_added = True # Build the search string - search_str = os.path.join(dirname, text + '*') + search_str = os.path.join(dirname, completion_token + '*') # Expand "~" to the real user directory search_str = os.path.expanduser(search_str) + # If the text being completed does not appear at the beginning of the token being completed, + # which can happen if there are spaces, save off the index where our search text begins in the + # search string so we can return only that portion of the completed paths to readline + if len(completion_token) - len(text) > 0: + starting_index = search_str.rfind(text + '*') + else: + starting_index = 0 + # Find all matching path completions - path_completions = glob.glob(search_str) + full_matches = 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)] + full_matches = [c for c in full_matches 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)] + full_matches = [c for c in full_matches if os.path.isdir(c)] + + # Don't append a space or closing quote to directory + if len(full_matches) == 1 and not os.path.isfile(full_matches[0]): + set_allow_appended_space(False) + set_allow_closing_quote(False) + + # Build the completion lists + completion_matches = [] + display_matches = [] + + for cur_match in full_matches: - # Get the basename of the paths - completions = [] - for c in path_completions: - basename = os.path.basename(c) + # Only keep where text started for the tab completion + completion_matches.append(cur_match[starting_index:]) + + # Display only the basename of this path in the tab-completion suggestions + display_matches.append(os.path.basename(cur_match)) # Add a separator after directories if the next character isn't already a separator - if os.path.isdir(c) and add_trailing_sep_if_dir: - basename += os.path.sep + if os.path.isdir(cur_match) and add_trailing_sep_if_dir: + completion_matches[-1] += os.path.sep + display_matches[-1] += os.path.sep + + # Remove cwd if it was added + if cwd_added: + completion_matches = [cur_path.replace(cwd + os.path.sep, '', 1) for cur_path in completion_matches] + + # Restore a tilde if we expanded one + if tilde_expanded: + completion_matches = [cur_path.replace(user_path, '~', 1) for cur_path in completion_matches] + + # Set the matches that will display as tab-completion suggestions + set_matches_to_display(display_matches) + + completion_matches.sort() + return completion_matches + + +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)) - completions.append(basename) + exes_list = list(exes_set) + exes_list.sort() + return exes_list - # 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 +def shell_cmd_complete(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 + """ + + # Get all tokens through the one being completed + tokens = tokens_for_completion(line, begidx, endidx) + if tokens is None: + return [] + + completion_token = tokens[-1] + + # Don't tab complete anything if no shell command has been started + if not complete_blank and len(completion_token) == 0: + return [] + + # If there are no path characters in this token, then do shell command completion in the user's path + if os.path.sep not in completion_token: + # These matches are already sorted + full_matches = get_exes_in_path(completion_token) + + # We will only keep where the text value starts for the tab completions + starting_index = len(completion_token) - len(text) + completion_matches = [cur_exe[starting_index:] for cur_exe in full_matches] + + # Use the full name of the executables for the completions that are displayed + display_matches = full_matches + set_matches_to_display(display_matches) + + return completion_matches + + # Otherwise look for executables in the given path + else: + return path_complete(text, line, begidx, endidx, dir_exe_only=True) + + +# BrokenPipeError and FileNotFoundError exist only in Python 3. Use IOError for Python 2. +if six.PY3: + BROKEN_PIPE_ERROR = BrokenPipeError + FILE_NOT_FOUND_ERROR = FileNotFoundError +else: + BROKEN_PIPE_ERROR = FILE_NOT_FOUND_ERROR = IOError + +# On some systems, pyperclip will import gtk for its clipboard functionality. +# The following code is a workaround for gtk interfering with printing from a background +# thread while the CLI thread is blocking in raw_input() in Python 2 on Linux. +if six.PY2 and sys.platform.startswith('lin'): + try: + # noinspection PyUnresolvedReferences + import gtk + gtk.set_interactive(0) + except ImportError: + pass + +__version__ = '0.8.2' + +# Pyparsing enablePackrat() can greatly speed up parsing, but problems have been seen in Python 3 in the past +pyparsing.ParserElement.enablePackrat() + +# Override the default whitespace chars in Pyparsing so that newlines are not treated as whitespace +pyparsing.ParserElement.setDefaultWhitespaceChars(' \t') + + +# 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 +# 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" + +# Use POSIX or Non-POSIX (Windows) rules for splitting a command-line string into a list of arguments via shlex.split() +POSIX_SHLEX = False + +# Strip outer quotes for convenience if POSIX_SHLEX = False +STRIP_QUOTES_FOR_NON_POSIX = True + +# For @options commands, pass a list of argument strings instead of a single argument string to the do_* methods +USE_ARG_LIST = True + + +def set_posix_shlex(val): + """ Allows user of cmd2 to choose between POSIX and non-POSIX splitting of args for decorated commands. + + :param val: bool - True => POSIX, False => Non-POSIX + """ + global POSIX_SHLEX + POSIX_SHLEX = val + + +def set_strip_quotes(val): + """ Allows user of cmd2 to choose whether to automatically strip outer-quotes when POSIX_SHLEX is False. + + :param val: bool - True => strip quotes on args for decorated commands if POSIX_SHLEX is False. + """ + global STRIP_QUOTES_FOR_NON_POSIX + STRIP_QUOTES_FOR_NON_POSIX = val + + +def set_use_arg_list(val): + """ Allows user of cmd2 to choose between passing @options commands an argument string or list of arg strings. + + :param val: bool - True => arg is a list of strings, False => arg is a string (for @options commands) + """ + global USE_ARG_LIST + USE_ARG_LIST = val class OptionParser(optparse.OptionParser): @@ -1125,9 +1527,11 @@ class Cmd(cmd.Cmd): # 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() @@ -1281,6 +1685,7 @@ class Cmd(cmd.Cmd): # 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 @@ -1288,6 +1693,7 @@ class Cmd(cmd.Cmd): # 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: @@ -1333,20 +1739,6 @@ class Cmd(cmd.Cmd): 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 @@ -1366,6 +1758,62 @@ class Cmd(cmd.Cmd): return subcommand_names + @staticmethod + def _display_matches_gnu_readline(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 matches_to_display if it is set. 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 matches_to_display is None: + display_matches = matches + else: + display_matches = matches_to_display + + # Eliminate duplicates and sort + display_set = set(display_matches) + display_matches = list(display_set) + display_matches.sort() + + # Display the matches + display_match_list_gnu_readline(substitution, display_matches, longest_match_length) + + @staticmethod + def _display_matches_pyreadline(matches): + """ + cmd2's default pyreadline function that prints tab-completion matches to the screen + This exists to allow the printing of matches_to_display if it is set. 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 matches_to_display is None: + display_matches = matches + else: + display_matches = matches_to_display + + # Eliminate duplicates and sort + display_set = set(display_matches) + display_matches = list(display_set) + display_matches.sort() + + # Display the matches + display_match_list_pyreadline(display_matches) + + # ----- Methods which override stuff in cmd ----- + def complete(self, text, state): """Override of command method which returns the next possible completion for 'text'. @@ -1380,23 +1828,54 @@ class Cmd(cmd.Cmd): :param text: str - the current word that user is typing :param state: int - non-negative integer """ + if state == 0: + unclosed_quote = '' + set_completion_defaults() + + # GNU readline specific way to override the completions display function + if readline_lib: + readline.set_completion_display_matches_hook(self._display_matches_gnu_readline) + + # pyreadline specific way to override the completions display function + elif sys.platform.startswith('win'): + readline.rl.mode._display_completions = self._display_matches_pyreadline + + # lstrip the original line 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 + # 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) + + # Not all of our shortcuts are readline word break delimiters. '!' is an example of this. + # Therefore those shortcuts become part of the text variable. 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 @@ -1408,18 +1887,55 @@ class Cmd(cmd.Cmd): # Overwrite line to pass into completers line = expanded_line - if command == '': - compfunc = self.completedefault - else: + # Get all tokens through the one being completed + tokens = tokens_for_completion(line, begidx, endidx, preserve_quotes=True) - # Get the completion function for this command + # 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 + + # Get the status of quotes in the token being completed + completion_token = tokens[-1] + + if len(completion_token) == 1: + # Check if the token being completed has an unclosed quote + first_char = completion_token[0] + if first_char in QUOTES: + unclosed_quote = first_char + + elif len(completion_token) > 1: + first_char = completion_token[0] + last_char = completion_token[-1] + + # Check if the token being completed has an unclosed quote + if first_char in QUOTES and first_char != last_char: + unclosed_quote = first_char + + # If the cursor is right after a closed quote, then insert a space + else: + prior_char = line[begidx - 1] + if not unclosed_quote and prior_char in QUOTES: + self.completion_matches = [' '] + return self.completion_matches[state] + + # Check if a valid command was entered + if command not in self.get_all_commands(): + # Check if this command should be run as a shell command + if self.default_to_shell and command in get_exes_in_path(command): + compfunc = functools.partial(path_complete) + else: + compfunc = self.completedefault + + # A valid command was entered + else: + # 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 @@ -1433,65 +1949,187 @@ class Cmd(cmd.Cmd): # Call the completer function self.completion_matches = compfunc(text, line, begidx, endidx) + if len(self.completion_matches) > 0: + + # Get the common prefix of all matches. This is what is added to the token being completed. + common_prefix = os.path.commonprefix(self.completion_matches) + + # Check if we need to add an opening quote + if len(completion_token) == 0 or completion_token[0] not in QUOTES: + + # If anything that will be in the token being completed contains a space, then + # we must add an opening quote to the token on screen + if ' ' in completion_token or ' ' in common_prefix: + + # Mark that there is now an unclosed quote + unclosed_quote = '"' + + # Find in the original line on screen where our token starts + starting_index = 0 + for token_index, cur_token in enumerate(tokens): + starting_index = origline.find(cur_token) + if token_index < len(tokens) - 1: + starting_index += len(cur_token) + + # Get where text started in the original line. + # Account for whether we shifted begidx due to a shortcut. + orig_begidx = readline.get_begidx() + if shortcut_to_restore: + orig_begidx += len(shortcut_to_restore) + + # If the token started at begidx, then all we have to do is prepend + # an opening quote to all the completions. Readline will do the rest. + # This is always true in the case where there is a shortcut to restore. + if starting_index == orig_begidx: + self.completion_matches = ['"' + match for match in self.completion_matches] + + # The token started after begidx, therefore we need to manually insert an + # opening quote before the token in the readline buffer. + else: + # GNU readline specific way to insert an opening quote + if readline_lib: + # Get and save the current cursor position + rl_point = ctypes.c_int.in_dll(readline_lib, "rl_point") + orig_rl_point = rl_point.value + + # Move the cursor where the token being completed begins to insert the opening quote + rl_point.value = starting_index + readline.insert_text('"') + + # Restore the cursor 1 past where it was the since we shifted everything + rl_point.value = orig_rl_point + 1 + + # Since we just shifted the whole command line over by one, readline will begin + # inserting the text one spot to the left of where we want it since it still has + # the original values of begidx and endidx and we can't change them. Therefore we + # must prepend to every match the character right before the text variable so it + # doesn't get erased. + saved_char = completion_token[(len(text) + 1) * -1] + self.completion_matches = [saved_char + match for match in self.completion_matches] + + # pyreadline specific way to insert an opening quote + elif sys.platform.startswith('win'): + # Save the current cursor position + orig_rl_point = readline.rl.mode.l_buffer.point + + # Move the cursor where the token being completed begins to insert the opening quote + readline.rl.mode.l_buffer.point = starting_index + readline.insert_text('"') + + # Shift the cursor and completion indexes by 1 to account for the added quote + readline.rl.mode.l_buffer.point = orig_rl_point + 1 + readline.rl.mode.begidx += 1 + readline.rl.mode.endidx += 1 + + # Check if we need to restore a shortcut in the tab completions + if shortcut_to_restore: + # If matches_to_display has not been set, then set it to self.completion_matches + # before we restore the shortcut so the tab completion suggestions that display to + # the user don't have the shortcut character. + if matches_to_display is None: + set_matches_to_display(self.completion_matches) + + # Prepend all tab completions with the shortcut so it doesn't get erased from the command line + self.completion_matches = [shortcut_to_restore + match for match in self.completion_matches] + 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 = basic_complete(text, line, begidx, endidx, strs_to_match) - # Add command names - strs_to_match.extend(self.get_command_names()) + # Eliminate duplicates and sort + matches_set = set(self.completion_matches) + self.completion_matches = list(matches_set) + self.completion_matches.sort() - # Perform matching - completions = [cur_str for cur_str in strs_to_match if cur_str.startswith(text)] + # Handle single result + if len(self.completion_matches) == 1: + str_to_append = '' - # 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] += ' ' + # Add a closing quote if needed + if allow_closing_quote and unclosed_quote: + str_to_append += unclosed_quote - self.completion_matches = completions + # If we are at the end of the line, then add a space + if allow_appended_space and endidx == len(line): + str_to_append += ' ' + + self.completion_matches[0] = 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 = tokens_for_completion(line, begidx, endidx) + if tokens is None: return [] - completions = [] + completion_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) + completion_matches = basic_complete(text, line, begidx, endidx, strs_to_match) - # 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] += ' ' + # Check if we are completing a subcommand + elif index == subcmd_index: - completions.sort() - return completions + # Match subcommands if any exist + command = tokens[cmd_index] + completion_matches = basic_complete(text, line, begidx, endidx, self.get_subcommands(command)) + + return completion_matches # noinspection PyUnusedLocal def sigint_handler(self, signum, frame): @@ -1502,8 +2140,10 @@ class Cmd(cmd.Cmd): :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() @@ -1511,7 +2151,8 @@ class Cmd(cmd.Cmd): 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) @@ -1837,7 +2478,7 @@ class Cmd(cmd.Cmd): 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: @@ -1951,8 +2592,9 @@ class Cmd(cmd.Cmd): 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('-', '')) + + # Use the same completer delimiters as Bash + readline.set_completer_delims(" \t\n\"'@><=;|&(:") readline.parse_and_bind(self.completekey + ": complete") except NameError: pass @@ -2041,7 +2683,7 @@ Usage: Usage: alias [<name> <value>] 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) @@ -2099,6 +2741,9 @@ Usage: Usage: unalias [-a] name [name ...] 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() @@ -2106,39 +2751,27 @@ Usage: Usage: unalias [-a] name [name ...] 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, _): @@ -2283,92 +2916,44 @@ Usage: Usage: unalias [-a] name [name ...] """Execute a command as if at the OS prompt. Usage: shell <command> [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): + @staticmethod + def complete_shell(text, line, begidx, endidx): """Handles tab completion of executable commands and local file system paths for the shell 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 - :return: List[str] - a list of possible tab completions + :return: List[str] - 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) - 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) + index_dict = {1: shell_cmd_complete} + return index_based_complete(text, line, begidx, endidx, index_dict, path_complete) def cmd_with_subs_completer(self, text, line, begidx, endidx, base): """ @@ -2401,24 +2986,21 @@ Usage: Usage: unalias [-a] name [name ...] :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 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 = tokens_for_completion(line, begidx, endidx) + if tokens is None: return [] - completions = [] + completion_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: @@ -2448,11 +3030,11 @@ Usage: Usage: unalias [-a] name [name ...] completer = 'complete_{}_{}'.format(base, subcommand) try: compfunc = getattr(self, completer) - completions = compfunc(text, line, begidx, endidx) + completion_matches = compfunc(text, line, begidx, endidx) except AttributeError: pass - return completions + return completion_matches # noinspection PyBroadException def do_py(self, arg): @@ -2561,7 +3143,10 @@ Paths or arguments that contain spaces must be enclosed in quotes sys.argv = orig_args # Enable tab-completion for pyscript command - complete_pyscript = functools.partial(path_complete) + @staticmethod + def complete_pyscript(text, line, begidx, endidx): + index_dict = {1: path_complete} + return 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: @@ -2703,7 +3288,10 @@ The editor used is determined by the ``editor`` settable parameter. os.system('"{}"'.format(self.editor)) # Enable tab-completion for edit command - complete_edit = functools.partial(path_complete) + @staticmethod + def complete_edit(text, line, begidx, endidx): + index_dict = {1: path_complete} + return index_based_complete(text, line, begidx, endidx, index_dict) @property def _current_script_dir(self): @@ -2792,7 +3380,10 @@ 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 complete_load(text, line, begidx, endidx): + index_dict = {1: path_complete} + return index_based_complete(text, line, begidx, endidx, index_dict) @staticmethod def is_text_file(file_path): |