diff options
Diffstat (limited to 'cmd2/cmd2.py')
-rw-r--r-- | cmd2/cmd2.py | 370 |
1 files changed, 236 insertions, 134 deletions
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 171109df..172d5264 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -30,6 +30,8 @@ Git repository on GitHub at https://github.com/python-cmd2/cmd2 # setting is True import argparse import cmd +import enum +import functools import glob import inspect import os @@ -95,6 +97,40 @@ except ImportError: # pragma: no cover ipython_available = False +class CompletionMode(enum.Enum): + """Enum for what type of tab completion to perform in cmd2.Cmd.read_input()""" + # Tab completion will be disabled during read_input() call + # Use of custom up-arrow history supported + NONE = 1 + + # read_input() will tab complete cmd2 commands and their arguments + # cmd2's command line history will be used for up arrow if history is not provided. + # Otherwise use of custom up-arrow history supported. + COMMANDS = 2 + + # read_input() will tab complete based on one of its following parameters: + # choices, choices_provider, completer, parser + # Use of custom up-arrow history supported + CUSTOM = 3 + + +class CustomCompletionSettings: + """Used by cmd2.Cmd.complete() to tab complete strings other than command arguments""" + def __init__(self, parser: argparse.ArgumentParser, *, preserve_quotes: bool = False): + """ + Initializer + + :param parser: arg parser defining format of string being tab completed + :param preserve_quotes: if True, then quoted tokens will keep their quotes when processed by + ArgparseCompleter. This is helpful in cases when you're tab completing + flag-like tokens (e.g. -o, --option) and you don't want them to be + treated as argparse flags when quoted. Set this to True if you plan + on passing the string to argparse with the tokens still quoted. + """ + self.parser = parser + self.preserve_quotes = preserve_quotes + + class _SavedReadlineSettings: """readline settings that are backed up when switching between readline environments""" @@ -1049,7 +1085,7 @@ class Cmd(cmd.Cmd): tmp_line = line[:endidx] tmp_line += unclosed_quote tmp_endidx = endidx + 1 - else: + else: # pragma: no cover # The parsing error is not caused by unclosed quotes. # Return empty lists since this means the line is malformed. return [], [] @@ -1164,7 +1200,7 @@ class Cmd(cmd.Cmd): """ # Get all tokens through the one being completed tokens, _ = self.tokens_for_completion(line, begidx, endidx) - if not tokens: + if not tokens: # pragma: no cover return [] completions_matches = [] @@ -1206,7 +1242,7 @@ class Cmd(cmd.Cmd): """ # Get all tokens through the one being completed tokens, _ = self.tokens_for_completion(line, begidx, endidx) - if not tokens: + if not tokens: # pragma: no cover return [] matches = [] @@ -1416,7 +1452,7 @@ class Cmd(cmd.Cmd): # Get all tokens through the one being completed. We want the raw tokens # so we can tell if redirection strings are quoted and ignore them. _, raw_tokens = self.tokens_for_completion(line, begidx, endidx) - if not raw_tokens: + if not raw_tokens: # pragma: no cover return [] # Must at least have the command @@ -1596,49 +1632,96 @@ class Cmd(cmd.Cmd): # Display matches using actual display function. This also redraws the prompt and line. orig_pyreadline_display(matches_to_display) - def _completion_for_command(self, text: str, line: str, begidx: int, - endidx: int, shortcut_to_restore: str) -> None: + def _perform_completion(self, text: str, line: str, begidx: int, endidx: int, + custom_settings: Optional[CustomCompletionSettings] = None) -> None: """ - Helper function for complete() that performs command-specific tab completion + Helper function for complete() that performs the actual completion :param text: the string prefix we are attempting to match (all matches must begin with it) :param line: the current input line with leading whitespace removed :param begidx: the beginning index of the prefix text :param endidx: the ending index of the prefix text - :param shortcut_to_restore: if not blank, then this shortcut was removed from text and needs to be - prepended to all the matches + :param custom_settings: optional prepopulated completion settings """ + from .argparse_completer import ArgparseCompleter + unclosed_quote = '' + command = None - # Parse the command line - statement = self.statement_parser.parse_command_only(line) - command = statement.command - cmd_set = self._cmd_to_command_sets[command] if command in self._cmd_to_command_sets else None - expanded_line = statement.command_and_args + # If custom_settings is None, then we are completing a command's arguments + if custom_settings is None: + # Parse the command line + statement = self.statement_parser.parse_command_only(line) + command = statement.command - # We overwrote line with a properly formatted but fully stripped version - # 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 + # Malformed command line (e.g. quoted command token) + if not command: + return + + expanded_line = statement.command_and_args - # 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 + # We overwrote line with a properly formatted but fully stripped version + # 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 - # Overwrite line to pass into completers - line = expanded_line + # 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 # Get all tokens through the one being completed tokens, raw_tokens = self.tokens_for_completion(line, begidx, endidx) - - # Check if we either had a parsing error or are trying to complete the command token - # The latter can happen if " or ' was entered as the command - if len(tokens) <= 1: + if not tokens: # pragma: no cover return + # Determine the completer function to use + if command is not None: + # Check if a macro was entered + if command in self.macros: + completer_func = self.path_complete + + # Check if a command was entered + elif command in self.get_all_commands(): + # Get the completer function for this command + completer_func = getattr(self, constants.COMPLETER_FUNC_PREFIX + command, None) + + if completer_func is None: + # There's no completer function, next see if the command uses argparse + func = self.cmd_func(command) + argparser = getattr(func, constants.CMD_ATTR_ARGPARSER, None) + + if func is not None and argparser is not None: + cmd_set = self._cmd_to_command_sets[command] if command in self._cmd_to_command_sets else None + completer = ArgparseCompleter(argparser, self) + preserve_quotes = getattr(func, constants.CMD_ATTR_PRESERVE_QUOTES) + + completer_func = functools.partial(completer.complete, + tokens=raw_tokens[1:] if preserve_quotes else tokens[1:], + cmd_set=cmd_set) + else: + completer_func = self.completedefault + + # Not a recognized macro or command + else: + # Check if this command should be run as a shell command + if self.default_to_shell and command in utils.get_exes_in_path(command): + completer_func = self.path_complete + else: + completer_func = self.completedefault + + # Otherwise we are completing the command token or performing custom completion + else: + completer = ArgparseCompleter(custom_settings.parser, self) + completer_func = functools.partial(completer.complete, + tokens=raw_tokens if custom_settings.preserve_quotes else tokens, + cmd_set=None) + # Text we need to remove from completions later text_to_remove = '' @@ -1666,40 +1749,9 @@ class Cmd(cmd.Cmd): text = text_to_remove + text begidx = actual_begidx - # Check if a macro was entered - if command in self.macros: - compfunc = self.path_complete - - # Check if a command was entered - elif command in self.get_all_commands(): - # Get the completer function for this command - compfunc = getattr(self, constants.COMPLETER_FUNC_PREFIX + command, None) - - if compfunc is None: - # There's no completer function, next see if the command uses argparse - func = self.cmd_func(command) - argparser = getattr(func, constants.CMD_ATTR_ARGPARSER, None) - - if func is not None and argparser is not None: - import functools - compfunc = functools.partial(self._complete_argparse_command, - argparser=argparser, - preserve_quotes=getattr(func, constants.CMD_ATTR_PRESERVE_QUOTES), - cmd_set=cmd_set) - else: - compfunc = self.completedefault - - # Not a recognized macro or command - else: - # Check if this command should be run as a shell command - if self.default_to_shell and command in utils.get_exes_in_path(command): - compfunc = self.path_complete - else: - compfunc = self.completedefault - # Attempt tab completion for redirection first, and if that isn't occurring, # call the completer function for the current command - self.completion_matches = self._redirect_complete(text, line, begidx, endidx, compfunc) + self.completion_matches = self._redirect_complete(text, line, begidx, endidx, completer_func) if self.completion_matches: @@ -1749,16 +1801,11 @@ class Cmd(cmd.Cmd): elif text_to_remove: self.completion_matches = [match.replace(text_to_remove, '', 1) for match in self.completion_matches] - # Check if we need to restore a shortcut in the tab completions - # so it doesn't get erased from the command line - if shortcut_to_restore: - self.completion_matches = [shortcut_to_restore + match for match in self.completion_matches] - # If we have one result, then add a closing quote if needed and allowed if len(self.completion_matches) == 1 and self.allow_closing_quote and unclosed_quote: self.completion_matches[0] += unclosed_quote - def complete(self, text: str, state: int) -> Optional[str]: + def complete(self, text: str, state: int, custom_settings: Optional[CustomCompletionSettings] = None) -> Optional[str]: """Override of cmd2's complete method which returns the next possible completion for 'text' This completer function is called by readline as complete(text, state), for state in 0, 1, 2, …, @@ -1770,6 +1817,7 @@ class Cmd(cmd.Cmd): :param text: the current word that user is typing :param state: non-negative integer + :param custom_settings: used when not tab completing the main command line :return: the next possible completion for text or None """ # noinspection PyBroadException @@ -1780,7 +1828,7 @@ class Cmd(cmd.Cmd): # Check if we are completing a multiline command if self._at_continuation_prompt: # lstrip and prepend the previously typed portion of this multiline command - lstripped_previous = self._multiline_in_progress.lstrip() + lstripped_previous = self._multiline_in_progress.lstrip().replace(constants.LINE_FEED, ' ') line = lstripped_previous + readline.get_line_buffer() # Increment the indexes to account for the prepended text @@ -1799,9 +1847,9 @@ class Cmd(cmd.Cmd): # Shortcuts are not word break characters when tab completing. Therefore shortcuts become part # of the text variable if there isn't a word break, like a space, after it. We need to remove it - # from text and update the indexes. This only applies if we are at the the beginning of the line. + # from text and update the indexes. This only applies if we are at the beginning of the command line. shortcut_to_restore = '' - if begidx == 0: + if begidx == 0 and custom_settings is None: for (shortcut, _) in self.statement_parser.shortcuts: if text.startswith(shortcut): # Save the shortcut to restore later @@ -1811,15 +1859,18 @@ class Cmd(cmd.Cmd): text = text[len(shortcut_to_restore):] begidx += len(shortcut_to_restore) break + else: + parser = DEFAULT_ARGUMENT_PARSER(add_help=False) + parser.add_argument('command', metavar="COMMAND", help="command, alias, or macro name", + choices=self._get_commands_aliases_and_macros_for_completion()) + custom_settings = CustomCompletionSettings(parser) - # If begidx is greater than 0, then we are no longer completing the first token (command name) - if begidx > 0: - self._completion_for_command(text, line, begidx, endidx, shortcut_to_restore) + self._perform_completion(text, line, begidx, endidx, custom_settings) - # Otherwise complete token against anything a user can run - else: - match_against = self._get_commands_aliases_and_macros_for_completion() - self.completion_matches = self.basic_complete(text, line, begidx, endidx, match_against) + # Check if we need to restore a shortcut in the tab completions + # so it doesn't get erased from the command line + if shortcut_to_restore: + self.completion_matches = [shortcut_to_restore + match for match in self.completion_matches] # If we have one result and we are at the end of the line, then add a space if allowed if len(self.completion_matches) == 1 and endidx == len(line) and self.allow_appended_space: @@ -1852,20 +1903,6 @@ class Cmd(cmd.Cmd): rl_force_redisplay() return None - def _complete_argparse_command(self, text: str, line: str, begidx: int, endidx: int, *, - argparser: argparse.ArgumentParser, - preserve_quotes: bool, - cmd_set: Optional[CommandSet] = None) -> List[str]: - """Completion function for argparse commands""" - from .argparse_completer import ArgparseCompleter - completer = ArgparseCompleter(argparser, self) - tokens, raw_tokens = self.tokens_for_completion(line, begidx, endidx) - - # To have tab completion parsing match command line parsing behavior, - # use preserve_quotes to determine if we parse the quoted or unquoted tokens. - tokens_to_parse = raw_tokens if preserve_quotes else tokens - return completer.complete_command(tokens_to_parse, text, line, begidx, endidx, cmd_set=cmd_set) - def in_script(self) -> bool: """Return whether a text script is running""" return self._current_script_dir is not None @@ -2518,36 +2555,115 @@ class Cmd(cmd.Cmd): # Set apply_style to False so default_error's style is not overridden self.perror(err_msg, apply_style=False) - def read_input(self, prompt: str, *, allow_completion: bool = False) -> str: + def read_input(self, prompt: str, *, + history: Optional[List[str]] = None, + completion_mode: CompletionMode = CompletionMode.NONE, + preserve_quotes: bool = False, + choices: Iterable = None, + choices_provider: Optional[Callable] = None, + completer: Optional[Callable] = None, + parser: Optional[argparse.ArgumentParser] = None) -> str: """ - Read input from appropriate stdin value. Also allows you to disable tab completion while input is being read. + Read input from appropriate stdin value. Also supports tab completion and up-arrow history while + input is being entered. :param prompt: prompt to display to user - :param allow_completion: if True, then tab completion of commands is enabled. This generally should be - set to False unless reading the command line. Defaults to False. + :param history: optional list of strings to use for up-arrow history. If completion_mode is + CompletionMode.COMMANDS and this is None, then cmd2's command list history will + be used. The passed in history will not be edited. It is the caller's responsibility + to add the returned input to history if desired. Defaults to None. + :param completion_mode: tells what type of tab completion to support. Tab completion only works when + self.use_rawinput is True and sys.stdin is a terminal. Defaults to + CompletionMode.NONE. + + The following optional settings apply when completion_mode is CompletionMode.CUSTOM: + + :param preserve_quotes: if True, then quoted tokens will keep their quotes when processed by + ArgparseCompleter. This is helpful in cases when you're tab completing + flag-like tokens (e.g. -o, --option) and you don't want them to be + treated as argparse flags when quoted. Set this to True if you plan + on passing the string to argparse with the tokens still quoted. + + A maximum of one of these should be provided: + + :param choices: iterable of accepted values for single argument + :param choices_provider: function that provides choices for single argument + :param completer: tab completion function that provides choices for single argument + :param parser: an argument parser which supports the tab completion of multiple arguments + :return: the line read from stdin with all trailing new lines removed :raises: any exceptions raised by input() and stdin.readline() """ - completion_disabled = False - orig_completer = None + readline_configured = False + saved_completer = None # type: Optional[Callable] + saved_history = None # type: Optional[List[str]] + + def configure_readline(): + """Configure readline tab completion and history""" + nonlocal readline_configured + nonlocal saved_completer + nonlocal saved_history + nonlocal parser + + if readline_configured: # pragma: no cover + return - def disable_completion(): - """Turn off completion while entering input""" - nonlocal orig_completer - nonlocal completion_disabled + # Configure tab completion + if self._completion_supported(): + saved_completer = readline.get_completer() + + # Disable completion + if completion_mode == CompletionMode.NONE: + # noinspection PyUnusedLocal + def complete_none(text: str, state: int): # pragma: no cover + return None + complete_func = complete_none - if self._completion_supported() and not completion_disabled: - orig_completer = readline.get_completer() - readline.set_completer(lambda *args, **kwargs: None) - completion_disabled = True + # Complete commands + elif completion_mode == CompletionMode.COMMANDS: + complete_func = self.complete + + # Set custom completion settings + else: + if parser is None: + parser = DEFAULT_ARGUMENT_PARSER(add_help=False) + parser.add_argument('arg', suppress_tab_hint=True, choices=choices, + choices_provider=choices_provider, completer=completer) - def enable_completion(): - """Restore tab completion when finished entering input""" - nonlocal completion_disabled + custom_settings = CustomCompletionSettings(parser, preserve_quotes=preserve_quotes) + complete_func = functools.partial(self.complete, custom_settings=custom_settings) - if self._completion_supported() and completion_disabled: - readline.set_completer(orig_completer) - completion_disabled = False + readline.set_completer(complete_func) + + # Overwrite history if not completing commands or new history was provided + if completion_mode != CompletionMode.COMMANDS or history is not None: + saved_history = [] + for i in range(1, readline.get_current_history_length() + 1): + # noinspection PyArgumentList + saved_history.append(readline.get_history_item(i)) + + readline.clear_history() + if history is not None: + for item in history: + readline.add_history(item) + + readline_configured = True + + def restore_readline(): + """Restore readline tab completion and history""" + nonlocal readline_configured + if not readline_configured: # pragma: no cover + return + + if self._completion_supported(): + readline.set_completer(saved_completer) + + if saved_history is not None: + readline.clear_history() + for item in saved_history: + readline.add_history(item) + + readline_configured = False # Check we are reading from sys.stdin if self.use_rawinput: @@ -2557,15 +2673,11 @@ class Cmd(cmd.Cmd): safe_prompt = rl_make_safe_prompt(prompt) with self.sigint_protection: - # Check if tab completion should be disabled - if not allow_completion: - disable_completion() + configure_readline() line = input(safe_prompt) finally: with self.sigint_protection: - # Check if we need to re-enable tab completion - if not allow_completion: - enable_completion() + restore_readline() else: line = input() if self.echo: @@ -2609,7 +2721,7 @@ class Cmd(cmd.Cmd): self.terminal_lock.release() except RuntimeError: pass - return self.read_input(prompt, allow_completion=True) + return self.read_input(prompt, completion_mode=CompletionMode.COMMANDS) except EOFError: return 'eof' finally: @@ -2618,7 +2730,7 @@ class Cmd(cmd.Cmd): def _set_up_cmd2_readline(self) -> _SavedReadlineSettings: """ - Set up readline with cmd2-specific settings + Called at beginning of command loop to set up readline with cmd2-specific settings :return: Class containing saved readline settings """ @@ -2653,7 +2765,7 @@ class Cmd(cmd.Cmd): def _restore_readline(self, readline_settings: _SavedReadlineSettings): """ - Restore saved readline settings + Called at end of command loop to restore saved readline settings :param readline_settings: the readline settings to restore """ @@ -3053,12 +3165,9 @@ class Cmd(cmd.Cmd): if func is None or argparser is None: return [] - # Combine the command and its subcommand tokens for the ArgparseCompleter - tokens = [command] + arg_tokens['subcommands'] - from .argparse_completer import ArgparseCompleter completer = ArgparseCompleter(argparser, self) - return completer.complete_subcommand_help(tokens, text, line, begidx, endidx) + return completer.complete_subcommand_help(text, line, begidx, endidx, arg_tokens['subcommands']) help_parser = DEFAULT_ARGUMENT_PARSER(description="List available commands or provide " "detailed help for a specific command") @@ -3089,10 +3198,9 @@ class Cmd(cmd.Cmd): if func is not None and argparser is not None: from .argparse_completer import ArgparseCompleter completer = ArgparseCompleter(argparser, self) - tokens = [args.command] + args.subcommands # Set end to blank so the help output matches how it looks when "command -h" is used - self.poutput(completer.format_help(tokens), end='') + self.poutput(completer.format_help(args.subcommands), end='') # If there is no help information then print an error elif help_func is None and (func is None or not func.__doc__): @@ -3297,10 +3405,6 @@ class Cmd(cmd.Cmd): if not response: continue - if rl_type != RlType.NONE: - hlen = readline.get_current_history_length() - if hlen >= 1: - readline.remove_history_item(hlen - 1) try: choice = int(response) if choice < 1: @@ -3335,7 +3439,7 @@ class Cmd(cmd.Cmd): # Use raw_tokens since quotes have been preserved _, raw_tokens = self.tokens_for_completion(line, begidx, endidx) - return completer.complete_command(raw_tokens, text, line, begidx, endidx) + return completer.complete(text, line, begidx, endidx, raw_tokens[1:]) # When tab completing value, we recreate the set command parser with a value argument specific to # the settable being edited. To make this easier, define a parent parser with all the common elements. @@ -4467,8 +4571,6 @@ class Cmd(cmd.Cmd): command being disabled. ex: message_to_print = "{} is currently disabled".format(COMMAND_NAME) """ - import functools - # If the commands is already disabled, then return if command in self.disabled_commands: return |