diff options
| -rwxr-xr-x | cmd2.py | 326 | ||||
| -rw-r--r-- | tests/test_completion.py | 59 |
2 files changed, 177 insertions, 208 deletions
@@ -164,7 +164,7 @@ allow_closing_quote = True # 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: complete_shell() and path_complete() always behave as if this flag is False +# 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, @@ -293,75 +293,12 @@ def display_match_list_pyreadline(matches): orig_pyreadline_display(matches) -QUOTES = ['"', "'"] - -# 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. +############################################################################################################ +# 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) - """ - global USE_ARG_LIST - USE_ARG_LIST = val +QUOTES = ['"', "'"] def tokens_for_completion(line, begidx, endidx, preserve_quotes=False): @@ -430,7 +367,6 @@ def tokens_for_completion(line, begidx, endidx, preserve_quotes=False): return tokens -# noinspection PyUnusedLocal def basic_complete(text, line, begidx, endidx, match_against): """ Performs tab completion against a list @@ -443,7 +379,7 @@ def basic_complete(text, line, begidx, endidx, match_against): :param begidx: int - the beginning index of the prefix text :param endidx: int - the ending index of the prefix text :param match_against: Collection - the list being matched against - :return: List[str] - a list of possible tab completions + :return: List[str] - a sorted list of possible tab completions """ # Make sure we were given an Collection with items to match against if not isinstance(match_against, Collection) or len(match_against) == 0: @@ -492,6 +428,7 @@ def basic_complete(text, line, begidx, endidx, match_against): # Set what matches will display set_matches_to_display(display_matches) + completion_matches.sort() return completion_matches @@ -510,7 +447,7 @@ def flag_based_complete(text, line, begidx, endidx, flag_dict, all_else=None): 2. function that performs tab completion (ex: path_complete) :param all_else: Collection or function - an optional parameter for tab completing any token that isn't preceded by a flag in flag_dict - :return: List[str] - a list of possible tab completions + :return: List[str] - a sorted list of possible tab completions """ # Get all tokens through the one being completed @@ -527,13 +464,14 @@ def flag_based_complete(text, line, begidx, endidx, flag_dict, all_else=None): if flag in flag_dict: match_against = flag_dict[flag] - # Perform tab completion using an Collection + # 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_matches = match_against(text, line, begidx, endidx) + completions_matches.sort() return completions_matches @@ -553,7 +491,7 @@ def index_based_complete(text, line, begidx, endidx, index_dict, all_else=None): 2. function that performs tab completion (ex: path_complete) :param all_else: Collection or function - an optional parameter for tab completing any token that isn't at an index in index_dict - :return: List[str] - a list of possible tab completions + :return: List[str] - a sorted list of possible tab completions """ # Get all tokens through the one being completed @@ -572,19 +510,20 @@ def index_based_complete(text, line, begidx, endidx, index_dict, all_else=None): else: match_against = all_else - # Perform tab completion using an Collection + # 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): completion_matches = match_against(text, line, begidx, endidx) + completion_matches.sort() 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 @@ -592,7 +531,7 @@ 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 through the one being completed @@ -705,9 +644,154 @@ def path_complete(text, line, begidx, endidx, dir_exe_only=False, dir_only=False # 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)) + + exes_list = list(exes_set) + exes_list.sort() + return exes_list + + +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): """Subclass of optparse.OptionParser which stores a reference to the do_* method it is parsing options for. @@ -1830,7 +1914,7 @@ class Cmd(cmd.Cmd): # 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 self._get_exes_in_path(command): + if self.default_to_shell and command in get_exes_in_path(command): compfunc = functools.partial(path_complete) else: compfunc = self.completedefault @@ -2000,6 +2084,7 @@ class Cmd(cmd.Cmd): def complete_help(self, text, line, begidx, endidx): """ Override of parent class method to handle tab completing subcommands and not showing hidden commands + Returns a sorted list of possible tab completions """ # The command is the token at index 1 in the command line @@ -2848,92 +2933,17 @@ Usage: Usage: unalias [-a] name [name ...] 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)] - - # 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)) - - # Sort the exes alphabetically - exes_list = list(exes_set) - exes_list.sort() - return exes_list - - def complete_shell(self, text, line, begidx, endidx): + 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 """ - - # The shell command is the token at index 1 in the command line - shell_cmd_index = 1 - - # Get all tokens through the one being completed - tokens = tokens_for_completion(line, begidx, endidx) - if tokens is None: - return [] - - # Get the index of the token being completed - index = len(tokens) - 1 - - if index < shell_cmd_index: - return [] - - # Complete the shell command - elif index == shell_cmd_index: - - completion_token = tokens[index] - - # Don't tab complete anything if no shell command has been started - if len(completion_token) == 0: - return [] - - # If there are no path characters in this token, it is OK to try shell command completion. - if not (completion_token.startswith('~') or os.path.sep in completion_token): - exes = self._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 exes] - - # Use the full name of the executables for the completions that are displayed - display_matches = exes - set_matches_to_display(display_matches) - - if completion_matches: - return completion_matches - - # 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): """ @@ -2966,7 +2976,7 @@ 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 diff --git a/tests/test_completion.py b/tests/test_completion.py index 8dfef023..aa32d444 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -174,10 +174,7 @@ def test_complete_empty_arg(cmd2_app): endidx = len(line) begidx = endidx - len(text) - # These matches would normally be sorted by complete() expected = cmd2_app.complete_help(text, line, begidx, endidx) - expected.sort() - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) assert first_match is not None and \ @@ -228,11 +225,7 @@ def test_cmd2_help_completion_multiple(cmd2_app): endidx = len(line) begidx = endidx - len(text) - # These matches would normally be sorted by complete() - matches = cmd2_app.complete_help(text, line, begidx, endidx) - matches.sort() - - assert matches == ['help', 'history'] + assert cmd2_app.complete_help(text, line, begidx, endidx) == ['help', 'history'] def test_cmd2_help_completion_nomatch(cmd2_app): text = 'fakecommand' @@ -337,13 +330,7 @@ def test_path_completion_multiple(request): begidx = endidx - len(text) expected = [text + 'cript.py', text + 'cript.txt', text + 'cripts' + os.path.sep] - expected.sort() - - # These matches would normally be sorted by complete() - matches = path_complete(text, line, begidx, endidx) - matches.sort() - - assert expected == matches + assert expected == path_complete(text, line, begidx, endidx) def test_path_completion_nomatch(request): test_dir = os.path.dirname(request.module.__file__) @@ -369,7 +356,7 @@ def test_default_to_shell_completion(cmd2_app, request): command = 'egrep' # Make sure the command is on the testing system - assert command in cmd2.Cmd._get_exes_in_path(command) + assert command in cmd2.get_exes_in_path(command) line = '{} {}'.format(command, text) endidx = len(line) @@ -482,11 +469,7 @@ def test_basic_completion_multiple(): endidx = len(line) begidx = endidx - len(text) - # These matches would normally be sorted by complete() - matches = basic_complete(text, line, begidx, endidx, food_item_strs) - matches.sort() - - assert matches == sorted(food_item_strs) + assert basic_complete(text, line, begidx, endidx, food_item_strs) == sorted(food_item_strs) def test_basic_completion_nomatch(): text = 'q' @@ -527,11 +510,7 @@ def test_flag_based_completion_multiple(): endidx = len(line) begidx = endidx - len(text) - # These matches would normally be sorted by complete() - matches = flag_based_complete(text, line, begidx, endidx, flag_dict) - matches.sort() - - assert matches == sorted(food_item_strs) + assert flag_based_complete(text, line, begidx, endidx, flag_dict) == sorted(food_item_strs) def test_flag_based_completion_nomatch(): text = 'q' @@ -577,11 +556,7 @@ def test_index_based_completion_multiple(): endidx = len(line) begidx = endidx - len(text) - # These matches would normally be sorted by complete() - matches = index_based_complete(text, line, begidx, endidx, index_dict) - matches.sort() - - assert matches == sorted(sport_item_strs) + assert index_based_complete(text, line, begidx, endidx, index_dict) == sorted(sport_item_strs) def test_index_based_completion_nomatch(): text = 'q' @@ -732,11 +707,7 @@ def test_cmd2_help_subcommand_completion_multiple(sc_app): endidx = len(line) begidx = endidx - len(text) - # These matches would normally be sorted by complete() - matches = sc_app.complete_help(text, line, begidx, endidx) - matches.sort() - - assert matches == ['bar', 'foo'] + assert sc_app.complete_help(text, line, begidx, endidx) == ['bar', 'foo'] def test_cmd2_help_subcommand_completion_nomatch(sc_app): @@ -844,13 +815,7 @@ def test_cmd2_help_submenu_completion_multiple(sb_app): endidx = len(line) begidx = endidx - len(text) - expected = ['py', 'pyscript'] - - # These matches would normally be sorted by complete() - matches = sb_app.complete_help(text, line, begidx, endidx) - matches.sort() - - assert matches == expected + assert sb_app.complete_help(text, line, begidx, endidx) == ['py', 'pyscript'] def test_cmd2_help_submenu_completion_nomatch(sb_app): @@ -867,10 +832,4 @@ def test_cmd2_help_submenu_completion_subcommands(sb_app): endidx = len(line) begidx = endidx - len(text) - expected = ['py', 'pyscript'] - - # These matches would normally be sorted by complete() - matches = sb_app.complete_help(text, line, begidx, endidx) - matches.sort() - - assert matches == expected + assert sb_app.complete_help(text, line, begidx, endidx) == ['py', 'pyscript'] |
