summaryrefslogtreecommitdiff
path: root/cmd2.py
diff options
context:
space:
mode:
Diffstat (limited to 'cmd2.py')
-rwxr-xr-xcmd2.py1257
1 files changed, 924 insertions, 333 deletions
diff --git a/cmd2.py b/cmd2.py
index f54691fb..a23400d8 100755
--- a/cmd2.py
+++ b/cmd2.py
@@ -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):