summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md4
-rw-r--r--cmd2/__init__.py2
-rw-r--r--cmd2/ansi.py4
-rw-r--r--cmd2/argparse_completer.py52
-rw-r--r--cmd2/cmd2.py337
-rw-r--r--cmd2/rl_utils.py4
-rw-r--r--cmd2/table_creator.py6
-rw-r--r--cmd2/utils.py39
-rw-r--r--docs/api/utils.rst27
-rw-r--r--docs/features/completion.rst12
-rw-r--r--examples/read_input.py112
-rw-r--r--tests/test_argparse_completer.py12
-rwxr-xr-xtests/test_cmd2.py36
-rwxr-xr-xtests/test_completion.py5
-rwxr-xr-xtests/test_history.py1
-rw-r--r--tests_isolated/test_commandset/test_commandset.py2
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