diff options
author | Kevin Van Brunt <kmvanbrunt@gmail.com> | 2020-09-09 03:06:21 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-09-09 03:06:21 -0400 |
commit | c50def1eff00f7f44adcb911d215ace65d16aa8a (patch) | |
tree | 62575c664a651027cea415cea574454d1e4637ca | |
parent | a975432ea87b8bde7d879f6e0974dcaffedc5f78 (diff) | |
parent | 1a2095e5c373430e5aa4bda2ee24f65f4527a002 (diff) | |
download | cmd2-git-c50def1eff00f7f44adcb911d215ace65d16aa8a.tar.gz |
Merge pull request #991 from python-cmd2/read_input
Read input enhancements
-rw-r--r-- | CHANGELOG.md | 4 | ||||
-rw-r--r-- | cmd2/__init__.py | 2 | ||||
-rw-r--r-- | cmd2/ansi.py | 4 | ||||
-rw-r--r-- | cmd2/argparse_completer.py | 52 | ||||
-rw-r--r-- | cmd2/cmd2.py | 337 | ||||
-rw-r--r-- | cmd2/rl_utils.py | 4 | ||||
-rw-r--r-- | cmd2/table_creator.py | 6 | ||||
-rw-r--r-- | cmd2/utils.py | 39 | ||||
-rw-r--r-- | docs/api/utils.rst | 27 | ||||
-rw-r--r-- | docs/features/completion.rst | 12 | ||||
-rw-r--r-- | examples/read_input.py | 112 | ||||
-rw-r--r-- | tests/test_argparse_completer.py | 12 | ||||
-rwxr-xr-x | tests/test_cmd2.py | 36 | ||||
-rwxr-xr-x | tests/test_completion.py | 5 | ||||
-rwxr-xr-x | tests/test_history.py | 1 | ||||
-rw-r--r-- | tests_isolated/test_commandset/test_commandset.py | 2 |
16 files changed, 478 insertions, 177 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d3adc68..bd5fb102 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ * Moved `basic_complete` from utils into `cmd2.Cmd` class. * Moved `CompletionError` to exceptions.py * ``Namespace.__statement__`` has been removed. Use `Namespace.cmd2_statement.get()` instead. +* Enhancements + * Added support for custom tab completion and up-arrow input history to `cmd2.Cmd2.read_input`. + See [read_input.py](https://github.com/python-cmd2/cmd2/blob/master/examples/read_input.py) + for an example. ## 1.4.0 (TBD, 2020) * Enhancements diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 35b163b1..f507dc28 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -36,4 +36,4 @@ from .exceptions import Cmd2ArgparseError, CommandSetRegistrationError, Completi from . import plugin from .parsing import Statement from .py_bridge import CommandResult -from .utils import categorize, Settable +from .utils import categorize, CompletionMode, CustomCompletionSettings, Settable diff --git a/cmd2/ansi.py b/cmd2/ansi.py index f172b87f..afef06ce 100644 --- a/cmd2/ansi.py +++ b/cmd2/ansi.py @@ -3,9 +3,9 @@ Support for ANSI escape sequences which are used for things like applying style to text, setting the window title, and asynchronous alerts. """ +import enum import functools import re -from enum import Enum from typing import IO, Any, List, Union import colorama @@ -49,7 +49,7 @@ The default is ``STYLE_TERMINAL``. ANSI_STYLE_RE = re.compile(r'\x1b\[[^m]*m') -class ColorBase(Enum): +class ColorBase(enum.Enum): """ Base class used for defining color enums. See fg and bg classes for examples. diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 2c1923fc..316d4666 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -186,10 +186,19 @@ class ArgparseCompleter: if isinstance(action, argparse._SubParsersAction): self._subcommand_action = action - def complete_command(self, tokens: List[str], text: str, line: str, begidx: int, endidx: int, *, - cmd_set: Optional[CommandSet] = None) -> List[str]: + def complete(self, text: str, line: str, begidx: int, endidx: int, tokens: List[str], *, + cmd_set: Optional[CommandSet] = None) -> List[str]: """ - Complete the command using the argparse metadata and provided argument dictionary + Complete text using argparse metadata + + :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 tokens: list of argument tokens being passed to the parser + :param cmd_set: if tab completing a command, the CommandSet the command's function belongs to, if applicable. + Defaults to None. + :raises: CompletionError for various types of tab completion errors """ if not tokens: @@ -266,7 +275,7 @@ class ArgparseCompleter: ############################################################################################# # Parse all but the last token ############################################################################################# - for token_index, token in enumerate(tokens[1:-1], start=1): + for token_index, token in enumerate(tokens[:-1]): # If we're in a positional REMAINDER arg, force all future tokens to go to that if pos_arg_state is not None and pos_arg_state.is_remainder: @@ -364,8 +373,8 @@ class ArgparseCompleter: completer = ArgparseCompleter(self._subcommand_action.choices[token], self._cmd2_app, parent_tokens=parent_tokens) - return completer.complete_command(tokens[token_index:], text, line, begidx, endidx, - cmd_set=cmd_set) + return completer.complete(text, line, begidx, endidx, tokens[token_index + 1:], + cmd_set=cmd_set) else: # Invalid subcommand entered, so no way to complete remaining tokens return [] @@ -409,9 +418,8 @@ class ArgparseCompleter: # Check if we are completing a flag's argument if flag_arg_state is not None: - completion_results = self._complete_for_arg(flag_arg_state, text, line, - begidx, endidx, consumed_arg_values, - cmd_set=cmd_set) + completion_results = self._complete_arg(text, line, begidx, endidx, flag_arg_state, consumed_arg_values, + cmd_set=cmd_set) # If we have results, then return them if completion_results: @@ -431,9 +439,8 @@ class ArgparseCompleter: action = remaining_positionals.popleft() pos_arg_state = _ArgumentState(action) - completion_results = self._complete_for_arg(pos_arg_state, text, line, - begidx, endidx, consumed_arg_values, - cmd_set=cmd_set) + completion_results = self._complete_arg(text, line, begidx, endidx, pos_arg_state, consumed_arg_values, + cmd_set=cmd_set) # If we have results, then return them if completion_results: @@ -538,23 +545,23 @@ class ArgparseCompleter: return completions - def complete_subcommand_help(self, tokens: List[str], text: str, line: str, begidx: int, endidx: int) -> List[str]: + def complete_subcommand_help(self, text: str, line: str, begidx: int, endidx: int, tokens: List[str]) -> List[str]: """ Supports cmd2's help command in the completion of subcommand names - :param tokens: command line tokens :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 tokens: arguments passed to command/subcommand :return: List of subcommand completions """ # If our parser has subcommands, we must examine the tokens and check if they are subcommands # If so, we will let the subcommand's parser handle the rest of the tokens via another ArgparseCompleter. if self._subcommand_action is not None: - for token_index, token in enumerate(tokens[1:], start=1): + for token_index, token in enumerate(tokens): if token in self._subcommand_action.choices: completer = ArgparseCompleter(self._subcommand_action.choices[token], self._cmd2_app) - return completer.complete_subcommand_help(tokens[token_index:], text, line, begidx, endidx) + return completer.complete_subcommand_help(text, line, begidx, endidx, tokens[token_index + 1:]) elif token_index == len(tokens) - 1: # Since this is the last token, we will attempt to complete it return self._cmd2_app.basic_complete(text, line, begidx, endidx, self._subcommand_action.choices) @@ -565,24 +572,23 @@ class ArgparseCompleter: def format_help(self, tokens: List[str]) -> str: """ Supports cmd2's help command in the retrieval of help text - :param tokens: command line tokens + :param tokens: arguments passed to help command :return: help text of the command being queried """ # If our parser has subcommands, we must examine the tokens and check if they are subcommands # If so, we will let the subcommand's parser handle the rest of the tokens via another ArgparseCompleter. if self._subcommand_action is not None: - for token_index, token in enumerate(tokens[1:], start=1): + for token_index, token in enumerate(tokens): if token in self._subcommand_action.choices: completer = ArgparseCompleter(self._subcommand_action.choices[token], self._cmd2_app) - return completer.format_help(tokens[token_index:]) + return completer.format_help(tokens[token_index + 1:]) else: break return self._parser.format_help() - def _complete_for_arg(self, arg_state: _ArgumentState, - text: str, line: str, begidx: int, endidx: int, - consumed_arg_values: Dict[str, List[str]], *, - cmd_set: Optional[CommandSet] = None) -> List[str]: + def _complete_arg(self, text: str, line: str, begidx: int, endidx: int, + arg_state: _ArgumentState, consumed_arg_values: Dict[str, List[str]], *, + cmd_set: Optional[CommandSet] = None) -> List[str]: """ Tab completion routine for an argparse argument :return: list of completions diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 171109df..86e511c7 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -30,6 +30,7 @@ Git repository on GitHub at https://github.com/python-cmd2/cmd2 # setting is True import argparse import cmd +import functools import glob import inspect import os @@ -1049,7 +1050,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 +1165,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 +1207,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 +1417,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 +1597,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[utils.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 + + # Malformed command line (e.g. quoted command token) + if not command: + return + + expanded_line = statement.command_and_args - # 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 + # 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 - # 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 + # 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 + # 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 +1714,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 +1766,12 @@ 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[utils.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 +1783,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 +1794,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 +1813,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 +1825,19 @@ class Cmd(cmd.Cmd): text = text[len(shortcut_to_restore):] begidx += len(shortcut_to_restore) break + else: + # No shortcut was found. Complete the command token. + 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 = utils.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 +1870,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 +2522,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: utils.CompletionMode = utils.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 + + # Configure tab completion + if self._completion_supported(): + saved_completer = readline.get_completer() - def disable_completion(): - """Turn off completion while entering input""" - nonlocal orig_completer - nonlocal completion_disabled + # Disable completion + if completion_mode == utils.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 == utils.CompletionMode.COMMANDS: + complete_func = self.complete - def enable_completion(): - """Restore tab completion when finished entering input""" - nonlocal completion_disabled + # 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) - if self._completion_supported() and completion_disabled: - readline.set_completer(orig_completer) - completion_disabled = False + custom_settings = utils.CustomCompletionSettings(parser, preserve_quotes=preserve_quotes) + complete_func = functools.partial(self.complete, custom_settings=custom_settings) + + readline.set_completer(complete_func) + + # Overwrite history if not completing commands or new history was provided + if completion_mode != utils.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 +2640,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 +2688,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=utils.CompletionMode.COMMANDS) except EOFError: return 'eof' finally: @@ -2618,7 +2697,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 +2732,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 +3132,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 +3165,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 +3372,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 +3406,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 +4538,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 diff --git a/cmd2/rl_utils.py b/cmd2/rl_utils.py index 099d76b7..9163efd8 100644 --- a/cmd2/rl_utils.py +++ b/cmd2/rl_utils.py @@ -2,8 +2,8 @@ """ Imports the proper readline for the platform and provides utility functions for it """ +import enum import sys -from enum import Enum # Prefer statically linked gnureadline if available (for macOS compatibility due to issues with libedit) try: @@ -19,7 +19,7 @@ except ImportError: pass -class RlType(Enum): +class RlType(enum.Enum): """Readline library types we recognize""" GNU = 1 PYREADLINE = 2 diff --git a/cmd2/table_creator.py b/cmd2/table_creator.py index 7a5c826c..fc2398f2 100644 --- a/cmd2/table_creator.py +++ b/cmd2/table_creator.py @@ -6,10 +6,10 @@ The general use case is to inherit from TableCreator to create a table class wit There are already implemented and ready-to-use examples of this below TableCreator's code. """ import copy +import enum import functools import io from collections import deque -from enum import Enum from typing import Any, Optional, Sequence, Tuple, Union from wcwidth import wcwidth @@ -39,14 +39,14 @@ EMPTY = '' SPACE = ' ' -class HorizontalAlignment(Enum): +class HorizontalAlignment(enum.Enum): """Horizontal alignment of text in a cell""" LEFT = 1 CENTER = 2 RIGHT = 3 -class VerticalAlignment(Enum): +class VerticalAlignment(enum.Enum): """Vertical alignment of text in a cell""" TOP = 1 MIDDLE = 2 diff --git a/cmd2/utils.py b/cmd2/utils.py index c5874d6e..b89d57bb 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -1,8 +1,10 @@ # coding=utf-8 """Shared utility functions""" +import argparse import collections import collections.abc as collections_abc +import enum import functools import glob import inspect @@ -12,7 +14,6 @@ import subprocess import sys import threading import unicodedata -from enum import Enum from typing import Any, Callable, Dict, Iterable, List, Optional, TextIO, Union from . import constants @@ -651,7 +652,7 @@ class RedirectionSavedState: self.saved_redirecting = saved_redirecting -class TextAlignment(Enum): +class TextAlignment(enum.Enum): """Horizontal text alignment""" LEFT = 1 CENTER = 2 @@ -1017,3 +1018,37 @@ def get_defining_class(meth): if isinstance(cls, type): return cls return getattr(meth, '__objclass__', None) # handle special descriptor objects + + +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 diff --git a/docs/api/utils.rst b/docs/api/utils.rst index 188f5b16..9276587f 100644 --- a/docs/api/utils.rst +++ b/docs/api/utils.rst @@ -36,6 +36,33 @@ IO Handling :members: +Tab Completion +-------------- + +.. autoclass:: cmd2.utils.CompletionMode + + .. attribute:: NONE + + Tab completion will be disabled during read_input() call. Use of custom + up-arrow history supported. + + .. attribute:: COMMANDS + + 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. + + .. attribute:: CUSTOM + + read_input() will tab complete based on one of its following parameters + (choices, choices_provider, completer, parser). Use of custom up-arrow + history supported + +.. autoclass:: cmd2.utils.CustomCompletionSettings + + .. automethod:: __init__ + + Text Alignment -------------- diff --git a/docs/features/completion.rst b/docs/features/completion.rst index 0e6bedd9..3894a4eb 100644 --- a/docs/features/completion.rst +++ b/docs/features/completion.rst @@ -89,6 +89,7 @@ completion hints. .. _argparse-based: + Tab Completion Using argparse Decorators ---------------------------------------- @@ -132,6 +133,17 @@ any of the 3 completion parameters: ``choices``, ``choices_provider``, and See the argparse_completion_ example or the implementation of the built-in :meth:`~cmd2.Cmd.do_set` command for demonstration of how this is used. + +Custom Completion with ``read_input()`` +-------------------------------------------------- + +``cmd2`` provides :attr:`cmd2.Cmd.read_input` as an alternative to Python's +``input()`` function. ``read_input`` supports configurable tab completion and +up-arrow history at the prompt. See read_input_ example for a demonstration. + +.. _read_input: https://github.com/python-cmd2/cmd2/blob/master/examples/read_input.py + + For More Information -------------------- diff --git a/examples/read_input.py b/examples/read_input.py new file mode 100644 index 00000000..e772a106 --- /dev/null +++ b/examples/read_input.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python +# coding=utf-8 +""" +A simple example demonstrating the various ways to call cmd2.Cmd.read_input() for input history and tab completion +""" +from typing import List + +import cmd2 + +EXAMPLE_COMMANDS = "Example Commands" + + +class ReadInputApp(cmd2.Cmd): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.prompt = "\n" + self.prompt + self.custom_history = ['history 1', 'history 2'] + + @cmd2.with_category(EXAMPLE_COMMANDS) + def do_basic(self, _) -> None: + """Call read_input with no history or tab completion""" + self.poutput("Tab completion and up-arrow history is off") + try: + self.read_input("> ") + except EOFError: + pass + + @cmd2.with_category(EXAMPLE_COMMANDS) + def do_basic_with_history(self, _) -> None: + """Call read_input with custom history and no tab completion""" + self.poutput("Tab completion is off but using custom history") + try: + input_str = self.read_input("> ", history=self.custom_history) + except EOFError: + pass + else: + self.custom_history.append(input_str) + + @cmd2.with_category(EXAMPLE_COMMANDS) + def do_commands(self, _) -> None: + """Call read_input the same way cmd2 prompt does to read commands""" + self.poutput("Tab completing and up-arrow history configured for commands") + try: + self.read_input("> ", completion_mode=cmd2.CompletionMode.COMMANDS) + except EOFError: + pass + + @cmd2.with_category(EXAMPLE_COMMANDS) + def do_custom_choices(self, _) -> None: + """Call read_input to use custom history and choices""" + self.poutput("Tab completing with static choices list and using custom history") + try: + input_str = self.read_input("> ", history=self.custom_history, completion_mode=cmd2.CompletionMode.CUSTOM, + choices=['choice_1', 'choice_2', 'choice_3']) + except EOFError: + pass + else: + self.custom_history.append(input_str) + + # noinspection PyMethodMayBeStatic + def choices_provider(self) -> List[str]: + """Example choices provider function""" + return ["from_provider_1", "from_provider_2", "from_provider_3"] + + @cmd2.with_category(EXAMPLE_COMMANDS) + def do_custom_choices_provider(self, _) -> None: + """Call read_input to use custom history and choices provider function""" + self.poutput("Tab completing with choices from provider function and using custom history") + try: + input_str = self.read_input("> ", history=self.custom_history, completion_mode=cmd2.CompletionMode.CUSTOM, + choices_provider=ReadInputApp.choices_provider) + except EOFError: + pass + else: + self.custom_history.append(input_str) + + @cmd2.with_category(EXAMPLE_COMMANDS) + def do_custom_completer(self, _) -> None: + """all read_input to use custom history and completer function""" + self.poutput("Tab completing paths and using custom history") + try: + input_str = self.read_input("> ", history=self.custom_history, completion_mode=cmd2.CompletionMode.CUSTOM, + completer=cmd2.Cmd.path_complete) + self.custom_history.append(input_str) + except EOFError: + pass + + @cmd2.with_category(EXAMPLE_COMMANDS) + def do_custom_parser(self, _) -> None: + """Call read_input to use a custom history and an argument parser""" + parser = cmd2.Cmd2ArgumentParser(prog='', description="An example parser") + parser.add_argument('-o', '--option', help="an optional arg") + parser.add_argument('arg_1', help="a choice for this arg", metavar='arg_1', + choices=['my_choice', 'your_choice']) + parser.add_argument('arg_2', help="path of something", completer=cmd2.Cmd.path_complete) + + self.poutput("Tab completing with argument parser and using custom history") + self.poutput(parser.format_usage()) + + try: + input_str = self.read_input("> ", history=self.custom_history, completion_mode=cmd2.CompletionMode.CUSTOM, + parser=parser) + except EOFError: + pass + else: + self.custom_history.append(input_str) + + +if __name__ == '__main__': + import sys + app = ReadInputApp() + sys.exit(app.cmdloop()) diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 4ae3e339..5c579b5c 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -576,8 +576,8 @@ def test_autocomp_blank_token(ac_app): begidx = endidx - len(text) completer = ArgparseCompleter(ac_app.completer_parser, ac_app) - tokens = ['completer', '-c', blank, text] - completions = completer.complete_command(tokens, text, line, begidx, endidx) + tokens = ['-c', blank, text] + completions = completer.complete(text, line, begidx, endidx, tokens) assert sorted(completions) == sorted(ArgparseCompleterTester.completions_for_pos_1) # Blank arg for first positional will be consumed. Therefore we expect to be completing the second positional. @@ -587,8 +587,8 @@ def test_autocomp_blank_token(ac_app): begidx = endidx - len(text) completer = ArgparseCompleter(ac_app.completer_parser, ac_app) - tokens = ['completer', blank, text] - completions = completer.complete_command(tokens, text, line, begidx, endidx) + tokens = [blank, text] + completions = completer.complete(text, line, begidx, endidx, tokens) assert sorted(completions) == sorted(ArgparseCompleterTester.completions_for_pos_2) @@ -1001,7 +1001,7 @@ def test_complete_command_no_tokens(ac_app): parser = Cmd2ArgumentParser() ac = ArgparseCompleter(parser, ac_app) - completions = ac.complete_command(tokens=[], text='', line='', begidx=0, endidx=0) + completions = ac.complete(text='', line='', begidx=0, endidx=0, tokens=[]) assert not completions @@ -1011,7 +1011,7 @@ def test_complete_command_help_no_tokens(ac_app): parser = Cmd2ArgumentParser() ac = ArgparseCompleter(parser, ac_app) - completions = ac.complete_subcommand_help(tokens=[], text='', line='', begidx=0, endidx=0) + completions = ac.complete_subcommand_help(text='', line='', begidx=0, endidx=0, tokens=[]) assert not completions diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index be6f52d1..f5c8dbc6 100755 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1437,6 +1437,42 @@ def test_read_input_rawinput_true(capsys, monkeypatch): line = app.read_input(prompt_str) assert line == input_str + # Run custom history code + import readline + readline.add_history('old_history') + custom_history = ['cmd1', 'cmd2'] + line = app.read_input(prompt_str, history=custom_history, completion_mode=cmd2.CompletionMode.NONE) + assert line == input_str + readline.clear_history() + + # Run all completion modes + line = app.read_input(prompt_str, completion_mode=cmd2.CompletionMode.NONE) + assert line == input_str + + line = app.read_input(prompt_str, completion_mode=cmd2.CompletionMode.COMMANDS) + assert line == input_str + + # custom choices + custom_choices = ['choice1', 'choice2'] + line = app.read_input(prompt_str, completion_mode=cmd2.CompletionMode.CUSTOM, + choices=custom_choices) + assert line == input_str + + # custom choices_provider + line = app.read_input(prompt_str, completion_mode=cmd2.CompletionMode.CUSTOM, + choices_provider=cmd2.Cmd.get_all_commands) + assert line == input_str + + # custom completer + line = app.read_input(prompt_str, completion_mode=cmd2.CompletionMode.CUSTOM, + completer=cmd2.Cmd.path_complete) + assert line == input_str + + # custom parser + line = app.read_input(prompt_str, completion_mode=cmd2.CompletionMode.CUSTOM, + parser=cmd2.Cmd2ArgumentParser()) + assert line == input_str + # isatty is False with mock.patch('sys.stdin.isatty', mock.MagicMock(name='isatty', return_value=False)): # echo True diff --git a/tests/test_completion.py b/tests/test_completion.py index ac4216bb..48f93a5a 100755 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -22,7 +22,6 @@ import pytest import cmd2 from cmd2 import utils from examples.subcommands import SubcommandsExample - from .conftest import complete_tester, normalize, run_cmd # List of strings used with completion functions @@ -858,9 +857,9 @@ def test_no_completer(cmd2_app): first_match = complete_tester(text, line, begidx, endidx, cmd2_app) assert first_match is not None and cmd2_app.completion_matches == expected -def test_quote_as_command(cmd2_app): +def test_wordbreak_in_command(cmd2_app): text = '' - line = '" {}'.format(text) + line = '"{}'.format(text) endidx = len(line) begidx = endidx - len(text) diff --git a/tests/test_history.py b/tests/test_history.py index 6fa16ad8..86c52592 100755 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -27,6 +27,7 @@ except ImportError: # def test_readline_remove_history_item(base_app): from cmd2.rl_utils import readline + readline.clear_history() assert readline.get_current_history_length() == 0 readline.add_history('this is a test') assert readline.get_current_history_length() == 1 diff --git a/tests_isolated/test_commandset/test_commandset.py b/tests_isolated/test_commandset/test_commandset.py index 319353a0..2729bcba 100644 --- a/tests_isolated/test_commandset/test_commandset.py +++ b/tests_isolated/test_commandset/test_commandset.py @@ -731,7 +731,7 @@ class SubclassCommandSetCase2(cmd2.CommandSet): def test_cross_commandset_completer(command_sets_manual): global complete_states_expected_self # This tests the different ways to locate the matching CommandSet when completing an argparse argument. - # Exercises the `_complete_for_arg` function of `ArgparseCompleter` in `argparse_completer.py` + # Exercises the `_complete_arg` function of `ArgparseCompleter` in `argparse_completer.py` #################################################################################################################### # This exercises Case 1 |