summaryrefslogtreecommitdiff
path: root/cmd2.py
diff options
context:
space:
mode:
Diffstat (limited to 'cmd2.py')
-rwxr-xr-xcmd2.py543
1 files changed, 368 insertions, 175 deletions
diff --git a/cmd2.py b/cmd2.py
index 7b5ba9e3..0158aa6c 100755
--- a/cmd2.py
+++ b/cmd2.py
@@ -165,6 +165,208 @@ def set_use_arg_list(val):
USE_ARG_LIST = val
+def flag_based_complete(text, line, begidx, endidx, flag_dict, default_completer=None):
+ """
+ Tab completes based on a particular flag preceding the text being completed
+ :param text: str - the string prefix we are attempting to match (all returned matches must begin with it)
+ :param line: str - the current input line with leading whitespace removed
+ :param begidx: int - the beginning index of the prefix text
+ :param endidx: int - the ending index of the prefix text
+ :param flag_dict: dict - dictionary whose structure is the following:
+ keys - flags (ex: -c, --create) that result in tab completion for the next
+ argument in the command line
+ values - there are two types of values
+ 1. iterable list of strings to match against (dictionaries, lists, etc.)
+ 2. function that performs tab completion (ex: path_complete)
+ :param default_completer: callable - an optional completer to use if no flags in flag_dict precede the text
+ being completed
+ :return: List[str] - a list of possible tab completions
+ """
+
+ # Get all tokens prior to text being completed
+ try:
+ prev_space_index = max(line.rfind(' ', 0, begidx), 0)
+ tokens = shlex.split(line[:prev_space_index], posix=POSIX_SHLEX)
+ except ValueError:
+ # Invalid syntax for shlex (Probably due to missing closing quote)
+ return []
+
+ # Nothing to do
+ if len(tokens) == 0:
+ return []
+
+ completions = []
+ flag_processed = False
+
+ # Must have at least the command and one argument for a flag to be present
+ if len(tokens) > 1:
+
+ # Get the argument that precedes the text being completed
+ flag = tokens[-1]
+
+ # Check if the flag is in the dictionary
+ if flag in flag_dict:
+
+ # Check if this flag does completions using an Iterable
+ if isinstance(flag_dict[flag], collections.Iterable):
+ flag_processed = True
+ strs_to_match = flag_dict[flag]
+ completions = [cur_str for cur_str in strs_to_match if cur_str.startswith(text)]
+
+ # If there is only 1 match and it's at the end of the line, then add a space
+ if len(completions) == 1 and endidx == len(line):
+ completions[0] += ' '
+
+ # Otherwise check if this flag does completions with a function
+ elif callable(flag_dict[flag]):
+ flag_processed = True
+ completer_func = flag_dict[flag]
+ completions = completer_func(text, line, begidx, endidx)
+
+ # Check if we need to run the default completer
+ if default_completer is not None and not flag_processed:
+ completions = default_completer(text, line, begidx, endidx)
+
+ completions.sort()
+ return completions
+
+
+def index_based_complete(text, line, begidx, endidx, index_dict, default_completer=None):
+ """
+ Tab completes based on a fixed position in the input string
+ :param text: str - the string prefix we are attempting to match (all returned matches must begin with it)
+ :param line: str - the current input line with leading whitespace removed
+ :param begidx: int - the beginning index of the prefix text
+ :param endidx: int - the ending index of the prefix text
+ :param index_dict: dict - dictionary whose structure is the following:
+ keys - 0-based token indexes into command line that determine which tokens
+ perform tab completion
+ values - there are two types of values
+ 1. iterable list of strings to match against (dictionaries, lists, etc.)
+ 2. function that performs tab completion (ex: path_complete)
+ :param default_completer: callable - an optional completer to use if the text being completed is not at
+ any index in index_dict
+ :return: List[str] - a list of possible tab completions
+ """
+
+ # Get all tokens prior to text being completed
+ try:
+ prev_space_index = max(line.rfind(' ', 0, begidx), 0)
+ tokens = shlex.split(line[:prev_space_index], posix=POSIX_SHLEX)
+ except ValueError:
+ # Invalid syntax for shlex (Probably due to missing closing quote)
+ return []
+
+ completions = []
+
+ # Must have at least the command
+ if len(tokens) > 0:
+
+ # Get the index of the text being completed
+ index = len(tokens)
+
+ # Check if the index is in the dictionary
+ if index in index_dict:
+
+ # Check if this index does completions using an Iterable
+ if isinstance(index_dict[index], collections.Iterable):
+ strs_to_match = index_dict[index]
+ completions = [cur_str for cur_str in strs_to_match if cur_str.startswith(text)]
+
+ # If there is only 1 match and it's at the end of the line, then add a space
+ if len(completions) == 1 and endidx == len(line):
+ completions[0] += ' '
+
+ # Otherwise check if this index does completions with a function
+ elif callable(index_dict[index]):
+ completer_func = index_dict[index]
+ completions = completer_func(text, line, begidx, endidx)
+
+ # Otherwise check if there is a default completer
+ elif default_completer is not None:
+ completions = default_completer(text, line, begidx, endidx)
+
+ completions.sort()
+ return completions
+
+
+def path_complete(text, line, begidx, endidx, dir_exe_only=False, dir_only=False):
+ """Method called to complete an input line by local file system path completion.
+
+ :param text: str - the string prefix we are attempting to match (all returned matches must begin with it)
+ :param line: str - the current input line with leading whitespace removed
+ :param begidx: int - the beginning index of the prefix text
+ :param endidx: int - the ending index of the prefix text
+ :param dir_exe_only: bool - only return directories and executables, not non-executable files
+ :param dir_only: bool - only return directories
+ :return: List[str] - a list of possible tab completions
+ """
+
+ # Determine if a trailing separator should be appended to directory completions
+ add_trailing_sep_if_dir = False
+ if endidx == len(line) or (endidx < len(line) and line[endidx] != os.path.sep):
+ add_trailing_sep_if_dir = True
+
+ add_sep_after_tilde = False
+ # If no path and no search text has been entered, then search in the CWD for *
+ if not text and line[begidx - 1] == ' ' and (begidx >= len(line) or line[begidx] == ' '):
+ search_str = os.path.join(os.getcwd(), '*')
+ else:
+ # Parse out the path being searched
+ prev_space_index = line.rfind(' ', 0, begidx)
+ dirname = line[prev_space_index + 1:begidx]
+
+ # Purposely don't match any path containing wildcards - what we are doing is complicated enough!
+ wildcards = ['*', '?']
+ for wildcard in wildcards:
+ if wildcard in dirname or wildcard in text:
+ return []
+
+ if not dirname:
+ dirname = os.getcwd()
+ elif dirname == '~':
+ # If tilde was used without separator, add a separator after the tilde in the completions
+ add_sep_after_tilde = True
+
+ # Build the search string
+ search_str = os.path.join(dirname, text + '*')
+
+ # Expand "~" to the real user directory
+ search_str = os.path.expanduser(search_str)
+
+ # Find all matching path completions
+ path_completions = glob.glob(search_str)
+
+ # If we only want directories and executables, filter everything else out first
+ if dir_exe_only:
+ path_completions = [c for c in path_completions if os.path.isdir(c) or os.access(c, os.X_OK)]
+ elif dir_only:
+ path_completions = [c for c in path_completions if os.path.isdir(c)]
+
+ # Get the basename of the paths
+ completions = []
+ for c in path_completions:
+ basename = os.path.basename(c)
+
+ # Add a separator after directories if the next character isn't already a separator
+ if os.path.isdir(c) and add_trailing_sep_if_dir:
+ basename += os.path.sep
+
+ completions.append(basename)
+
+ # If there is a single completion
+ if len(completions) == 1:
+ # If it is a file and we are at the end of the line, then add a space for convenience
+ if os.path.isfile(path_completions[0]) and endidx == len(line):
+ completions[0] += ' '
+ # If tilde was expanded without a separator, prepend one
+ elif os.path.isdir(path_completions[0]) and add_sep_after_tilde:
+ completions[0] = os.path.sep + completions[0]
+
+ completions.sort()
+ return completions
+
+
class OptionParser(optparse.OptionParser):
"""Subclass of optparse.OptionParser which stores a reference to the do_* method it is parsing options for.
@@ -1029,33 +1231,24 @@ class Cmd(cmd.Cmd):
return cmd_completion
- # noinspection PyUnusedLocal
- def complete_subcommand(self, text, line, begidx, endidx):
- """Readline tab-completion method for completing argparse sub-command names."""
- command, args, foo = self.parseline(line)
- arglist = args.split()
-
- if len(arglist) <= 1 and command + ' ' + args == line:
- funcname = self._func_named(command)
- if funcname:
- # Check to see if this function was decorated with an argparse ArgumentParser
- func = getattr(self, funcname)
- subcommand_names = func.__dict__.get('subcommand_names', None)
+ def get_subcommands(self, command):
+ """
+ Returns a list of a command's subcommands if they exist
+ :param command:
+ :return: A subcommand list or None
+ """
- # If this command has subcommands
- if subcommand_names is not None:
- arg = ''
- if arglist:
- arg = arglist[0]
+ subcommand_names = None
- matches = [sc for sc in subcommand_names if sc.startswith(arg)]
+ # Check if is a valid command
+ funcname = self._func_named(command)
- # If completing the sub-command name and get exactly 1 result and are at end of line, add a space
- if len(matches) == 1 and endidx == len(line):
- matches[0] += ' '
- return matches
+ if funcname:
+ # Check to see if this function was decorated with an argparse ArgumentParser
+ func = getattr(self, funcname)
+ subcommand_names = func.__dict__.get('subcommand_names', None)
- return []
+ return subcommand_names
def complete(self, text, state):
"""Override of command method which returns the next possible completion for 'text'.
@@ -1078,42 +1271,115 @@ class Cmd(cmd.Cmd):
stripped = len(origline) - len(line)
begidx = readline.get_begidx() - stripped
endidx = readline.get_endidx() - stripped
+
+ # If begidx is greater than 0, then the cursor is past the command
if begidx > 0:
- command, args, foo = self.parseline(line)
+
+ # Parse the command line
+ command, args, expanded_line = self.parseline(line)
+
+ # We overwrote line with a properly formatted but fully stripped version
+ # Restore the end spaces from the original since line is only supposed to be
+ # lstripped when passed to completer functions according to Python docs
+ rstripped_len = len(origline) - len(origline.rstrip())
+ expanded_line += ' ' * rstripped_len
+
+ # Fix the index values if expanded_line has a different size than line
+ if len(expanded_line) != len(line):
+ diff = len(expanded_line) - len(line)
+ begidx += diff
+ endidx += diff
+
+ # Overwrite line to pass into completers
+ line = expanded_line
+
if command == '':
compfunc = self.completedefault
else:
- arglist = args.split()
-
- compfunc = None
- # If the user has entered no more than a single argument after the command name
- if len(arglist) <= 1 and command + ' ' + args == line:
- funcname = self._func_named(command)
- if funcname:
- # Check to see if this function was decorated with an argparse ArgumentParser
- func = getattr(self, funcname)
- subcommand_names = func.__dict__.get('subcommand_names', None)
-
- # If this command has subcommands
- if subcommand_names is not None:
- compfunc = self.complete_subcommand
-
- if compfunc is None:
- # This command either doesn't have sub-commands or the user is past the point of entering one
- try:
- compfunc = getattr(self, 'complete_' + command)
- except AttributeError:
- compfunc = self.completedefault
+
+ # Get the completion function for this command
+ try:
+ compfunc = getattr(self, 'complete_' + command)
+ except AttributeError:
+ compfunc = self.completedefault
+
+ # If there are subcommands, then try completing those if the cursor is in
+ # the correct position, otherwise default to using compfunc
+ subcommands = self.get_subcommands(command)
+ if subcommands is not None:
+ index_dict = {1: subcommands}
+ compfunc = functools.partial(index_based_complete,
+ index_dict=index_dict,
+ default_completer=compfunc)
+
+ # Call the completer function
+ self.completion_matches = compfunc(text, line, begidx, endidx)
+
else:
- compfunc = self.completenames
+ # Complete the command against command names and shortcuts. By design, shortcuts that start with
+ # symbols not in self.identchars won't be tab completed since they are handled in the above if
+ # statement. This includes shortcuts like: ?, !, @, @@
+ strs_to_match = []
+
+ # If a command has been started, then match against shortcuts. This keeps shortcuts out of the
+ # full list of commands that show up when tab completion is done on an empty line.
+ if len(line) > 0:
+ for (shortcut, expansion) in self.shortcuts:
+ strs_to_match.append(shortcut)
- self.completion_matches = compfunc(text, line, begidx, endidx)
+ # Get command names
+ do_text = 'do_' + text
+ strs_to_match.extend([cur_name[3:] for cur_name in self.get_names() if cur_name.startswith(do_text)])
+
+ # Perform matching
+ completions = [cur_str for cur_str in strs_to_match if cur_str.startswith(text)]
+
+ # If there is only 1 match and it's at the end of the line, then add a space
+ if len(completions) == 1 and endidx == len(line):
+ completions[0] += ' '
+
+ self.completion_matches = completions
try:
return self.completion_matches[state]
except IndexError:
return None
+ def complete_help(self, text, line, begidx, endidx):
+ """
+ Override of parent class method to handle tab completing subcommands
+ """
+
+ # Get all tokens prior to text being completed
+ try:
+ prev_space_index = max(line.rfind(' ', 0, begidx), 0)
+ tokens = shlex.split(line[:prev_space_index], posix=POSIX_SHLEX)
+ except ValueError:
+ # Invalid syntax for shlex (Probably due to missing closing quote)
+ return []
+
+ completions = []
+
+ # If we have "help" and a completed command token, then attempt to match subcommands
+ if len(tokens) == 2:
+
+ # Match subcommands if any exist
+ subcommands = self.get_subcommands(tokens[1])
+ if subcommands is not None:
+ completions = [cur_sub for cur_sub in subcommands if cur_sub.startswith(text)]
+
+ # Run normal help completion from the parent class
+ else:
+ completions = cmd.Cmd.complete_help(self, text, line, begidx, endidx)
+
+ # If only 1 command has been matched and it's at the end of the line,
+ # then add a space if it has subcommands
+ if len(completions) == 1 and endidx == len(line) and self.get_subcommands(completions[0]) is not None:
+ completions[0] += ' '
+
+ completions.sort()
+ return completions
+
def precmd(self, statement):
"""Hook method executed just before the command is processed by ``onecmd()`` and after adding it to the history.
@@ -1200,13 +1466,25 @@ class Cmd(cmd.Cmd):
# Expand command shortcuts to the full command name
for (shortcut, expansion) in self.shortcuts:
if line.startswith(shortcut):
- line = line.replace(shortcut, expansion + ' ', 1)
+ # If the next character after the shortcut isn't a space, then insert one
+ shortcut_len = len(shortcut)
+ if len(line) == shortcut_len or line[shortcut_len] != ' ':
+ expansion += ' '
+
+ # Expand the shortcut
+ line = line.replace(shortcut, expansion, 1)
break
i, n = 0, len(line)
while i < n and line[i] in self.identchars:
i += 1
command, arg = line[:i], line[i:].strip()
+
+ # Make sure there is a space between the command and args
+ # This can occur when a character not in self.identchars bumps against the command (ex: help@)
+ if len(command) > 0 and len(arg) > 0 and line[len(command)] != ' ':
+ line = line.replace(command, command + ' ', 1)
+
return command, arg, line
def onecmd_plus_hooks(self, line):
@@ -1771,96 +2049,6 @@ class Cmd(cmd.Cmd):
proc = subprocess.Popen(command, stdout=self.stdout, shell=True)
proc.communicate()
- def path_complete(self, text, line, begidx, endidx, dir_exe_only=False, dir_only=False):
- """Method called to complete an input line by local file system path completion.
-
- :param text: str - the string prefix we are attempting to match (all returned matches must begin with it)
- :param line: str - the current input line with leading whitespace removed
- :param begidx: int - the beginning indexe of the prefix text
- :param endidx: int - the ending index of the prefix text
- :param dir_exe_only: bool - only return directories and executables, not non-executable files
- :param dir_only: bool - only return directories
- :return: List[str] - a list of possible tab completions
- """
- # Deal with cases like load command and @ key when path completion is immediately after a shortcut
- for (shortcut, expansion) in self.shortcuts:
- if line.startswith(shortcut):
- # If the next character after the shortcut isn't a space, then insert one and adjust indices
- shortcut_len = len(shortcut)
- if len(line) == shortcut_len or line[shortcut_len] != ' ':
- line = line.replace(shortcut, shortcut + ' ', 1)
- begidx += 1
- endidx += 1
- break
-
- # Determine if a trailing separator should be appended to directory completions
- add_trailing_sep_if_dir = False
- if endidx == len(line) or (endidx < len(line) and line[endidx] != os.path.sep):
- add_trailing_sep_if_dir = True
-
- add_sep_after_tilde = False
- # If no path and no search text has been entered, then search in the CWD for *
- if not text and line[begidx - 1] == ' ' and (begidx >= len(line) or line[begidx] == ' '):
- search_str = os.path.join(os.getcwd(), '*')
- else:
- # Parse out the path being searched
- prev_space_index = line.rfind(' ', 0, begidx)
- dirname = line[prev_space_index + 1:begidx]
-
- # Purposely don't match any path containing wildcards - what we are doing is complicated enough!
- wildcards = ['*', '?']
- for wildcard in wildcards:
- if wildcard in dirname or wildcard in text:
- return []
-
- if not dirname:
- dirname = os.getcwd()
- elif dirname == '~':
- # If tilde was used without separator, add a separator after the tilde in the completions
- add_sep_after_tilde = True
-
- # Build the search string
- search_str = os.path.join(dirname, text + '*')
-
- # Expand "~" to the real user directory
- search_str = os.path.expanduser(search_str)
-
- # Find all matching path completions
- path_completions = glob.glob(search_str)
-
- # If we only want directories and executables, filter everything else out first
- if dir_exe_only:
- path_completions = [c for c in path_completions if os.path.isdir(c) or os.access(c, os.X_OK)]
- elif dir_only:
- path_completions = [c for c in path_completions if os.path.isdir(c)]
-
- # Get the basename of the paths
- completions = []
- for c in path_completions:
- basename = os.path.basename(c)
-
- # Add a separator after directories if the next character isn't already a separator
- if os.path.isdir(c) and add_trailing_sep_if_dir:
- basename += os.path.sep
-
- completions.append(basename)
-
- # If there is a single completion
- if len(completions) == 1:
- # If it is a file and we are at the end of the line, then add a space for convenience
- if os.path.isfile(path_completions[0]) and endidx == len(line):
- completions[0] += ' '
- # If tilde was expanded without a separator, prepend one
- elif os.path.isdir(path_completions[0]) and add_sep_after_tilde:
- completions[0] = os.path.sep + completions[0]
-
- # If there are multiple completions, then sort them alphabetically
- return sorted(completions)
-
- # Enable tab completion of paths for relevant commands
- complete_edit = path_complete
- complete_load = path_complete
-
# noinspection PyUnusedLocal
@staticmethod
def _shell_command_complete(text, line, begidx, endidx):
@@ -1882,23 +2070,27 @@ class Cmd(cmd.Cmd):
# Get a list of every directory in the PATH environment variable and ignore symbolic links
paths = [p for p in os.getenv('PATH').split(os.path.pathsep) if not os.path.islink(p)]
+ # Use a set to store exe names since there can be duplicates
+ exes = set()
+
# Find every executable file in the PATH that matches the pattern
- exes = []
for path in paths:
full_path = os.path.join(path, text)
matches = [f for f in glob.glob(full_path + '*') if os.path.isfile(f) and os.access(f, os.X_OK)]
for match in matches:
- exes.append(os.path.basename(match))
+ exes.add(os.path.basename(match))
+
+ # Sort the exes alphabetically
+ results = list(exes)
+ results.sort()
# If there is a single completion and we are at end of the line, then add a space at the end for convenience
- if len(exes) == 1 and endidx == len(line):
- exes[0] += ' '
+ if len(results) == 1 and endidx == len(line):
+ results[0] += ' '
- # If there are multiple completions, then sort them alphabetically
- return sorted(exes)
+ return results
- # noinspection PyUnusedLocal
def complete_shell(self, text, line, begidx, endidx):
"""Handles tab completion of executable commands and local file system paths.
@@ -1909,49 +2101,44 @@ class Cmd(cmd.Cmd):
:return: List[str] - a list of possible tab completions
"""
- # First we strip off the shell command or shortcut key
- if line.startswith('!'):
- stripped_line = line.lstrip('!')
- initial_length = len('!')
- else:
- stripped_line = line[len('shell'):]
- initial_length = len('shell')
-
- line_parts = stripped_line.split()
-
- # Don't tab complete anything if user only typed shell or !
- if not line_parts:
+ # Get all tokens prior to text being completed
+ try:
+ prev_space_index = max(line.rfind(' ', 0, begidx), 0)
+ tokens = shlex.split(line[:prev_space_index], posix=POSIX_SHLEX)
+ except ValueError:
+ # Invalid syntax for shlex (Probably due to missing closing quote)
return []
- # Find the start index of the first thing after the shell or !
- cmd_start = line.find(line_parts[0], initial_length)
- cmd_end = cmd_start + len(line_parts[0])
+ # Nothing to do
+ if len(tokens) == 0:
+ return []
- # Check if we are in the command token
- if cmd_start <= begidx <= cmd_end:
+ # Check if we are still completing the shell command
+ elif len(tokens) == 1:
- # See if text is part of a path
- possible_path = line[cmd_start:begidx]
+ # Readline places begidx after ~ and path separators (/) so we need to get the whole token
+ # and see if it begins with a possible path in case we need to do path completion
+ # to find the shell command executables
+ cmd_token = line[prev_space_index + 1:begidx + 1]
- # There is nothing to search
- if len(possible_path) == 0 and not text:
+ # Don't tab complete anything if no shell command has been started
+ if len(cmd_token) == 0:
return []
- if os.path.sep not in possible_path and possible_path != '~':
- # The text before the search text is not a directory path.
- # It is OK to try shell command completion.
+ if not (cmd_token.startswith('~') or os.path.sep in cmd_token):
+ # No path characters are in this token, it is OK to try shell command completion.
command_completions = self._shell_command_complete(text, line, begidx, endidx)
if command_completions:
return command_completions
- # If we have no results, try path completion
- return self.path_complete(text, line, begidx, endidx, dir_exe_only=True)
+ # If we have no results, try path completion to find the shell commands
+ return path_complete(text, line, begidx, endidx, dir_exe_only=True)
- # Past command token
+ # Shell command has been completed
else:
# Do path completion
- return self.path_complete(text, line, begidx, endidx)
+ return path_complete(text, line, begidx, endidx)
# noinspection PyBroadException
def do_py(self, arg):
@@ -2059,8 +2246,8 @@ Paths or arguments that contain spaces must be enclosed in quotes
# Restore command line arguments to original state
sys.argv = orig_args
- # Enable tab completion of paths for pyscript command
- complete_pyscript = path_complete
+ # Enable tab-completion for pyscript command
+ complete_pyscript = functools.partial(path_complete)
# Only include the do_ipy() method if IPython is available on the system
if ipython_available:
@@ -2201,6 +2388,9 @@ The editor used is determined by the ``editor`` settable parameter.
else:
os.system('"{}"'.format(self.editor))
+ # Enable tab-completion for edit command
+ complete_edit = functools.partial(path_complete)
+
@property
def _current_script_dir(self):
"""Accessor to get the current script directory from the _script_dir LIFO queue."""
@@ -2287,6 +2477,9 @@ Script should contain one command per line, just like command would be typed in
self._script_dir.append(os.path.dirname(expanded_path))
+ # Enable tab-completion for load command
+ complete_load = functools.partial(path_complete)
+
@staticmethod
def is_text_file(file_path):
"""