summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorkotfu <kotfu@kotfu.net>2018-04-19 21:51:24 -0600
committerkotfu <kotfu@kotfu.net>2018-04-19 21:51:24 -0600
commit477666d0b3e097fb831729644b8861a983805981 (patch)
tree02217d3d2bb03499e511d8dd1774d161bb019a0f
parentb7cfb130c7c914478936366b748b04234b031119 (diff)
parent58fdd089cc064e71502dc1f094fd906d30523886 (diff)
downloadcmd2-git-477666d0b3e097fb831729644b8861a983805981.tar.gz
Merge branch 'master' into ply
-rw-r--r--.coveragerc2
-rw-r--r--CHANGELOG.md3
-rwxr-xr-xcmd2/argparse_completer.py830
-rwxr-xr-xcmd2/cmd2.py114
-rw-r--r--cmd2/rl_utils.py66
-rw-r--r--docs/requirements.txt1
-rwxr-xr-xexamples/tab_autocompletion.py428
-rwxr-xr-xmain.py12
-rwxr-xr-xsetup.py5
-rw-r--r--tests/conftest.py47
-rw-r--r--tests/test_acargparse.py53
-rw-r--r--tests/test_autocompletion.py256
-rw-r--r--tests/test_completion.py225
-rw-r--r--tox.ini6
14 files changed, 1923 insertions, 125 deletions
diff --git a/.coveragerc b/.coveragerc
index a50fe0e7..307e3a9a 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,7 +1,7 @@
# .coveragerc to control coverage.py
[run]
# Source
-source = cmd2.py
+source = cmd2/
# (boolean, default False): whether to measure branch coverage in addition to statement coverage.
branch = False
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 38b54efb..aa2e785f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,9 @@
## 0.9.0 (TBD, 2018)
* Enhancements
+ * Automatic completion of ``argparse`` arguments via ``cmd2.argparse_completer.AutoCompleter``
+ * See the [tab_autocompletion.py](https://github.com/python-cmd2/cmd2/blob/master/examples/tab_autocompletion.py) example for a demonstration of how to use this feature
* ``cmd2`` no longer depends on the ``six`` module
+ * ``cmd2`` is now a multi-file Python package instead of a single-file module
* Deletions (potentially breaking changes)
* Deleted all ``optparse`` code which had previously been deprecated in release 0.8.0
* The ``options`` decorator no longer exists
diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py
new file mode 100755
index 00000000..35f9342b
--- /dev/null
+++ b/cmd2/argparse_completer.py
@@ -0,0 +1,830 @@
+# coding=utf-8
+"""
+AutoCompleter interprets the argparse.ArgumentParser internals to automatically
+generate the completion options for each argument.
+
+How to supply completion options for each argument:
+ argparse Choices
+ - pass a list of values to the choices parameter of an argparse argument.
+ ex: parser.add_argument('-o', '--options', dest='options', choices=['An Option', 'SomeOtherOption'])
+
+ arg_choices dictionary lookup
+ arg_choices is a dict() mapping from argument name to one of 3 possible values:
+ ex:
+ parser = argparse.ArgumentParser()
+ parser.add_argument('-o', '--options', dest='options')
+ choices = {}
+ mycompleter = AutoCompleter(parser, completer, 1, choices)
+
+ - static list - provide a static list for each argument name
+ ex:
+ choices['options'] = ['An Option', 'SomeOtherOption']
+
+ - choices function - provide a function that returns a list for each argument name
+ ex:
+ def generate_choices():
+ return ['An Option', 'SomeOtherOption']
+ choices['options'] = generate_choices
+
+ - custom completer function - provide a completer function that will return the list
+ of completion arguments
+ ex 1:
+ def my_completer(text: str, line: str, begidx: int, endidx:int):
+ my_choices = [...]
+ return my_choices
+ choices['options'] = (my_completer)
+ ex 2:
+ def my_completer(text: str, line: str, begidx: int, endidx:int, extra_param: str, another: int):
+ my_choices = [...]
+ return my_choices
+ completer_params = {'extra_param': 'my extra', 'another': 5}
+ choices['options'] = (my_completer, completer_params)
+
+How to supply completion choice lists or functions for sub-commands:
+ subcmd_args_lookup is used to supply a unique pair of arg_choices and subcmd_args_lookup
+ for each sub-command in an argparser subparser group.
+ This requires your subparser group to be named with the dest parameter
+ ex:
+ parser = ArgumentParser()
+ subparsers = parser.add_subparsers(title='Actions', dest='action')
+
+ subcmd_args_lookup maps a named subparser group to a subcommand group dictionary
+ The subcommand group dictionary maps subcommand names to tuple(arg_choices, subcmd_args_lookup)
+
+ For more details of this more complex approach see tab_autocompletion.py in the examples
+
+Copyright 2018 Eric Lin <anselor@gmail.com>
+Released under MIT license, see LICENSE file
+"""
+
+import argparse
+from colorama import Fore
+import sys
+from typing import List, Dict, Tuple, Callable, Union
+
+
+# imports copied from argparse to support our customized argparse functions
+from argparse import ZERO_OR_MORE, ONE_OR_MORE, ArgumentError, _
+import re as _re
+
+
+from .rl_utils import rl_force_redisplay
+
+
+class _RangeAction(object):
+ def __init__(self, nargs: Union[int, str, Tuple[int, int], None]):
+ self.nargs_min = None
+ self.nargs_max = None
+
+ # pre-process special ranged nargs
+ if isinstance(nargs, tuple):
+ if len(nargs) != 2 or not isinstance(nargs[0], int) or not isinstance(nargs[1], int):
+ raise ValueError('Ranged values for nargs must be a tuple of 2 integers')
+ if nargs[0] >= nargs[1]:
+ raise ValueError('Invalid nargs range. The first value must be less than the second')
+ if nargs[0] < 0:
+ raise ValueError('Negative numbers are invalid for nargs range.')
+ narg_range = nargs
+ self.nargs_min = nargs[0]
+ self.nargs_max = nargs[1]
+ if narg_range[0] == 0:
+ if narg_range[1] > 1:
+ self.nargs_adjusted = '*'
+ else:
+ # this shouldn't use a range tuple, but yet here we are
+ self.nargs_adjusted = '?'
+ else:
+ self.nargs_adjusted = '+'
+ else:
+ self.nargs_adjusted = nargs
+
+
+class _StoreRangeAction(argparse._StoreAction, _RangeAction):
+ def __init__(self,
+ option_strings,
+ dest,
+ nargs=None,
+ const=None,
+ default=None,
+ type=None,
+ choices=None,
+ required=False,
+ help=None,
+ metavar=None):
+
+ _RangeAction.__init__(self, nargs)
+
+ argparse._StoreAction.__init__(self,
+ option_strings=option_strings,
+ dest=dest,
+ nargs=self.nargs_adjusted,
+ const=const,
+ default=default,
+ type=type,
+ choices=choices,
+ required=required,
+ help=help,
+ metavar=metavar)
+
+
+class _AppendRangeAction(argparse._AppendAction, _RangeAction):
+ def __init__(self,
+ option_strings,
+ dest,
+ nargs=None,
+ const=None,
+ default=None,
+ type=None,
+ choices=None,
+ required=False,
+ help=None,
+ metavar=None):
+
+ _RangeAction.__init__(self, nargs)
+
+ argparse._AppendAction.__init__(self,
+ option_strings=option_strings,
+ dest=dest,
+ nargs=self.nargs_adjusted,
+ const=const,
+ default=default,
+ type=type,
+ choices=choices,
+ required=required,
+ help=help,
+ metavar=metavar)
+
+
+def register_custom_actions(parser: argparse.ArgumentParser):
+ """Register custom argument action types"""
+ parser.register('action', None, _StoreRangeAction)
+ parser.register('action', 'store', _StoreRangeAction)
+ parser.register('action', 'append', _AppendRangeAction)
+
+
+class AutoCompleter(object):
+ """Automatically command line tab completion based on argparse parameters"""
+
+ class _ArgumentState(object):
+ def __init__(self):
+ self.min = None
+ self.max = None
+ self.count = 0
+ self.needed = False
+ self.variable = False
+
+ def reset(self):
+ """reset tracking values"""
+ self.min = None
+ self.max = None
+ self.count = 0
+ self.needed = False
+ self.variable = False
+
+ def __init__(self,
+ parser: argparse.ArgumentParser,
+ token_start_index: int = 1,
+ arg_choices: Dict[str, Union[List, Tuple, Callable]] = None,
+ subcmd_args_lookup: dict = None,
+ tab_for_arg_help: bool = True):
+ """
+ Create an AutoCompleter
+
+ :param parser: ArgumentParser instance
+ :param token_start_index: index of the token to start parsing at
+ :param arg_choices: dictionary mapping from argparse argument 'dest' name to list of choices
+ :param subcmd_args_lookup: mapping a sub-command group name to a tuple to fill the child\
+ AutoCompleter's arg_choices and subcmd_args_lookup parameters
+ """
+ if not subcmd_args_lookup:
+ subcmd_args_lookup = {}
+ forward_arg_choices = True
+ else:
+ forward_arg_choices = False
+ self._parser = parser
+ self._arg_choices = arg_choices.copy() if arg_choices is not None else {}
+ self._token_start_index = token_start_index
+ self._tab_for_arg_help = tab_for_arg_help
+
+ self._flags = [] # all flags in this command
+ self._flags_without_args = [] # all flags that don't take arguments
+ self._flag_to_action = {} # maps flags to the argparse action object
+ self._positional_actions = [] # argument names for positional arguments (by position index)
+ # maps action name to sub-command autocompleter:
+ # action_name -> dict(sub_command -> completer)
+ self._positional_completers = {}
+
+ # Start digging through the argparse structures.
+ # _actions is the top level container of parameter definitions
+ for action in self._parser._actions:
+ # if there are choices defined, record them in the arguments dictionary
+ if action.choices is not None:
+ self._arg_choices[action.dest] = action.choices
+
+ # if the parameter is flag based, it will have option_strings
+ if action.option_strings:
+ # record each option flag
+ for option in action.option_strings:
+ self._flags.append(option)
+ self._flag_to_action[option] = action
+ if action.nargs == 0:
+ self._flags_without_args.append(option)
+ else:
+ self._positional_actions.append(action)
+
+ if isinstance(action, argparse._SubParsersAction):
+ sub_completers = {}
+ sub_commands = []
+ args_for_action = subcmd_args_lookup[action.dest]\
+ if action.dest in subcmd_args_lookup else {}
+ for subcmd in action.choices:
+ (subcmd_args, subcmd_lookup) = args_for_action[subcmd] if \
+ subcmd in args_for_action else \
+ (arg_choices, subcmd_args_lookup) if forward_arg_choices else ({}, {})
+ subcmd_start = token_start_index + len(self._positional_actions)
+ sub_completers[subcmd] = AutoCompleter(action.choices[subcmd], subcmd_start,
+ arg_choices=subcmd_args,
+ subcmd_args_lookup=subcmd_lookup)
+ sub_commands.append(subcmd)
+ self._positional_completers[action.dest] = sub_completers
+ self._arg_choices[action.dest] = sub_commands
+
+ def complete_command(self, tokens: List[str], text: str, line: str, begidx: int, endidx: int) -> List[str]:
+ """Complete the command using the argparse metadata and provided argument dictionary"""
+ # Count which positional argument index we're at now. Loop through all tokens on the command line so far
+ # Skip any flags or flag parameter tokens
+ next_pos_arg_index = 0
+
+ pos_arg = AutoCompleter._ArgumentState()
+ pos_action = None
+
+ flag_arg = AutoCompleter._ArgumentState()
+ flag_action = None
+
+ matched_flags = []
+ current_is_positional = False
+ consumed_arg_values = {} # dict(arg_name -> [values, ...])
+
+ # the following are nested functions that have full access to all variables in the parent
+ # function including variables declared and updated after this function. Variable values
+ # are current at the point the nested functions are invoked (as in, they do not receive a
+ # snapshot of these values, they directly access the current state of variables in the
+ # parent function)
+
+ def consume_flag_argument() -> None:
+ """Consuming token as a flag argument"""
+ # we're consuming flag arguments
+ # if this is not empty and is not another potential flag, count towards flag arguments
+ if token and token[0] not in self._parser.prefix_chars and flag_action is not None:
+ flag_arg.count += 1
+
+ # does this complete a option item for the flag
+ arg_choices = self._resolve_choices_for_arg(flag_action)
+ # if the current token matches the current position's autocomplete argument list,
+ # track that we've used it already. Unless this is the current token, then keep it.
+ if not is_last_token and token in arg_choices:
+ consumed_arg_values.setdefault(flag_action.dest, [])
+ consumed_arg_values[flag_action.dest].append(token)
+
+ def consume_positional_argument() -> None:
+ """Consuming token as positional argument"""
+ pos_arg.count += 1
+
+ # does this complete a option item for the flag
+ arg_choices = self._resolve_choices_for_arg(pos_action)
+ # if the current token matches the current position's autocomplete argument list,
+ # track that we've used it already. Unless this is the current token, then keep it.
+ if not is_last_token and token in arg_choices:
+ consumed_arg_values.setdefault(pos_action.dest, [])
+ consumed_arg_values[pos_action.dest].append(token)
+
+ is_last_token = False
+ for idx, token in enumerate(tokens):
+ is_last_token = idx >= len(tokens) - 1
+ # Only start at the start token index
+ if idx >= self._token_start_index:
+ current_is_positional = False
+ # Are we consuming flag arguments?
+ if not flag_arg.needed:
+ # we're not consuming flag arguments, is the current argument a potential flag?
+ if len(token) > 0 and token[0] in self._parser.prefix_chars and\
+ (is_last_token or (not is_last_token and token != '-')):
+ # reset some tracking values
+ flag_arg.reset()
+ # don't reset positional tracking because flags can be interspersed anywhere between positionals
+ flag_action = None
+
+ # does the token fully match a known flag?
+ if token in self._flag_to_action:
+ flag_action = self._flag_to_action[token]
+ elif hasattr(self._parser, 'allow_abbrev') and self._parser.allow_abbrev:
+ candidates_flags = [flag for flag in self._flag_to_action if flag.startswith(token)]
+ if len(candidates_flags) == 1:
+ flag_action = self._flag_to_action[candidates_flags[0]]
+
+ if flag_action is not None:
+ # resolve argument counts
+ self._process_action_nargs(flag_action, flag_arg)
+ if not is_last_token and not isinstance(flag_action, argparse._AppendAction):
+ matched_flags.extend(flag_action.option_strings)
+
+ # current token isn't a potential flag
+ # - does the last flag accept variable arguments?
+ # - have we reached the max arg count for the flag?
+ elif not flag_arg.variable or flag_arg.count >= flag_arg.max:
+ # previous flag doesn't accept variable arguments, count this as a positional argument
+
+ # reset flag tracking variables
+ flag_arg.reset()
+ flag_action = None
+ current_is_positional = True
+
+ if len(token) > 0 and pos_action is not None and pos_arg.count < pos_arg.max:
+ # we have positional action match and we haven't reached the max arg count, consume
+ # the positional argument and move on.
+ consume_positional_argument()
+ elif pos_action is None or pos_arg.count >= pos_arg.max:
+ # if we don't have a current positional action or we've reached the max count for the action
+ # close out the current positional argument state and set up for the next one
+ pos_index = next_pos_arg_index
+ next_pos_arg_index += 1
+ pos_arg.reset()
+ pos_action = None
+
+ # are we at a sub-command? If so, forward to the matching completer
+ if pos_index < len(self._positional_actions):
+ action = self._positional_actions[pos_index]
+ pos_name = action.dest
+ if pos_name in self._positional_completers:
+ sub_completers = self._positional_completers[pos_name]
+ if token in sub_completers:
+ return sub_completers[token].complete_command(tokens, text, line,
+ begidx, endidx)
+ pos_action = action
+ self._process_action_nargs(pos_action, pos_arg)
+ consume_positional_argument()
+
+ elif not is_last_token and pos_arg.max is not None:
+ pos_action = None
+ pos_arg.reset()
+
+ else:
+ consume_flag_argument()
+
+ else:
+ consume_flag_argument()
+
+ # don't reset this if we're on the last token - this allows completion to occur on the current token
+ if not is_last_token and flag_arg.min is not None:
+ flag_arg.needed = flag_arg.count < flag_arg.min
+
+ # if we don't have a flag to populate with arguments and the last token starts with
+ # a flag prefix then we'll complete the list of flag options
+ completion_results = []
+ if not flag_arg.needed and len(tokens[-1]) > 0 and tokens[-1][0] in self._parser.prefix_chars:
+ return AutoCompleter.basic_complete(text, line, begidx, endidx,
+ [flag for flag in self._flags if flag not in matched_flags])
+ # we're not at a positional argument, see if we're in a flag argument
+ elif not current_is_positional:
+ # current_items = []
+ if flag_action is not None:
+ consumed = consumed_arg_values[flag_action.dest]\
+ if flag_action.dest in consumed_arg_values else []
+ # current_items.extend(self._resolve_choices_for_arg(flag_action, consumed))
+ completion_results = self._complete_for_arg(flag_action, text, line, begidx, endidx, consumed)
+ if not completion_results:
+ self._print_action_help(flag_action)
+
+ # ok, we're not a flag, see if there's a positional argument to complete
+ else:
+ if pos_action is not None:
+ pos_name = pos_action.dest
+ consumed = consumed_arg_values[pos_name] if pos_name in consumed_arg_values else []
+ completion_results = self._complete_for_arg(pos_action, text, line, begidx, endidx, consumed)
+ if not completion_results:
+ self._print_action_help(pos_action)
+
+ return completion_results
+
+ @staticmethod
+ def _process_action_nargs(action: argparse.Action, arg_state: _ArgumentState) -> None:
+ if isinstance(action, _RangeAction):
+ arg_state.min = action.nargs_min
+ arg_state.max = action.nargs_max
+ arg_state.variable = True
+ if arg_state.min is None or arg_state.max is None:
+ if action.nargs is None:
+ arg_state.min = 1
+ arg_state.max = 1
+ elif action.nargs == '+':
+ arg_state.min = 1
+ arg_state.max = float('inf')
+ arg_state.variable = True
+ elif action.nargs == '*':
+ arg_state.min = 0
+ arg_state.max = float('inf')
+ arg_state.variable = True
+ elif action.nargs == '?':
+ arg_state.min = 0
+ arg_state.max = 1
+ arg_state.variable = True
+ else:
+ arg_state.min = action.nargs
+ arg_state.max = action.nargs
+
+ def _complete_for_arg(self, action: argparse.Action,
+ text: str,
+ line: str,
+ begidx: int,
+ endidx: int,
+ used_values=()) -> List[str]:
+ if action.dest in self._arg_choices:
+ arg_choices = self._arg_choices[action.dest]
+
+ if isinstance(arg_choices, tuple) and len(arg_choices) > 0 and callable(arg_choices[0]):
+ completer = arg_choices[0]
+ list_args = None
+ kw_args = None
+ for index in range(1, len(arg_choices)):
+ if isinstance(arg_choices[index], list) or isinstance(arg_choices[index], tuple):
+ list_args = arg_choices[index]
+ elif isinstance(arg_choices[index], dict):
+ kw_args = arg_choices[index]
+ if list_args is not None and kw_args is not None:
+ return completer(text, line, begidx, endidx, *list_args, **kw_args)
+ elif list_args is not None:
+ return completer(text, line, begidx, endidx, *list_args)
+ elif kw_args is not None:
+ return completer(text, line, begidx, endidx, **kw_args)
+ else:
+ return completer(text, line, begidx, endidx)
+ else:
+ return AutoCompleter.basic_complete(text, line, begidx, endidx,
+ self._resolve_choices_for_arg(action, used_values))
+
+ return []
+
+ def _resolve_choices_for_arg(self, action: argparse.Action, used_values=()) -> List[str]:
+ if action.dest in self._arg_choices:
+ args = self._arg_choices[action.dest]
+ if callable(args):
+ args = args()
+
+ try:
+ iter(args)
+ except TypeError:
+ pass
+ else:
+ # filter out arguments we already used
+ args = [arg for arg in args if arg not in used_values]
+
+ if len(args) > 0:
+ return args
+
+ return []
+
+ def _print_action_help(self, action: argparse.Action) -> None:
+ if not self._tab_for_arg_help:
+ return
+ if action.option_strings:
+ flags = ', '.join(action.option_strings)
+ param = ''
+ if action.nargs is None or action.nargs != 0:
+ param += ' ' + str(action.dest).upper()
+
+ prefix = '{}{}'.format(flags, param)
+ else:
+ prefix = '{}'.format(str(action.dest).upper())
+
+ prefix = ' {0: <{width}} '.format(prefix, width=20)
+ pref_len = len(prefix)
+ help_lines = action.help.splitlines()
+ if len(help_lines) == 1:
+ print('\nHint:\n{}{}\n'.format(prefix, help_lines[0]))
+ else:
+ out_str = '\n{}'.format(prefix)
+ out_str += '\n{0: <{width}}'.format('', width=pref_len).join(help_lines)
+ print('\nHint:' + out_str + '\n')
+
+ # Redraw prompt and input line
+ rl_force_redisplay()
+
+ # noinspection PyUnusedLocal
+ @staticmethod
+ def basic_complete(text: str, line: str, begidx: int, endidx: int, match_against: List[str]) -> List[str]:
+ """
+ Performs tab completion against a list
+
+ :param text: str - the string prefix we are attempting to match (all returned matches must begin with it)
+ :param line: str - the current input line with leading whitespace removed
+ :param begidx: int - the beginning index of the prefix text
+ :param endidx: int - the ending index of the prefix text
+ :param match_against: Collection - the list being matched against
+ :return: List[str] - a list of possible tab completions
+ """
+ return [cur_match for cur_match in match_against if cur_match.startswith(text)]
+
+
+###############################################################################
+# Unless otherwise noted, everything below this point are copied from Python's
+# argparse implementation with minor tweaks to adjust output.
+# Changes are noted if it's buried in a block of copied code. Otherwise the
+# function will check for a special case and fall back to the parent function
+###############################################################################
+
+
+class ACHelpFormatter(argparse.HelpFormatter):
+ """Custom help formatter to configure ordering of help text"""
+
+ def _format_usage(self, usage, actions, groups, prefix):
+ if prefix is None:
+ prefix = _('Usage: ')
+
+ # if usage is specified, use that
+ if usage is not None:
+ usage %= dict(prog=self._prog)
+
+ # if no optionals or positionals are available, usage is just prog
+ elif usage is None and not actions:
+ usage = '%(prog)s' % dict(prog=self._prog)
+
+ # if optionals and positionals are available, calculate usage
+ elif usage is None:
+ prog = '%(prog)s' % dict(prog=self._prog)
+
+ # split optionals from positionals
+ optionals = []
+ positionals = []
+ # Begin cmd2 customization (separates required and optional, applies to all changes in this function)
+ required_options = []
+ for action in actions:
+ if action.option_strings:
+ if action.required:
+ required_options.append(action)
+ else:
+ optionals.append(action)
+ else:
+ positionals.append(action)
+ # End cmd2 customization
+
+ # build full usage string
+ format = self._format_actions_usage
+ action_usage = format(positionals + required_options + optionals, groups)
+ usage = ' '.join([s for s in [prog, action_usage] if s])
+
+ # wrap the usage parts if it's too long
+ text_width = self._width - self._current_indent
+ if len(prefix) + len(usage) > text_width:
+
+ # Begin cmd2 customization
+
+ # break usage into wrappable parts
+ part_regexp = r'\(.*?\)+|\[.*?\]+|\S+'
+ opt_usage = format(optionals, groups)
+ pos_usage = format(positionals, groups)
+ req_usage = format(required_options, groups)
+ opt_parts = _re.findall(part_regexp, opt_usage)
+ pos_parts = _re.findall(part_regexp, pos_usage)
+ req_parts = _re.findall(part_regexp, req_usage)
+ assert ' '.join(opt_parts) == opt_usage
+ assert ' '.join(pos_parts) == pos_usage
+ assert ' '.join(req_parts) == req_usage
+
+ # End cmd2 customization
+
+ # helper for wrapping lines
+ def get_lines(parts, indent, prefix=None):
+ lines = []
+ line = []
+ if prefix is not None:
+ line_len = len(prefix) - 1
+ else:
+ line_len = len(indent) - 1
+ for part in parts:
+ if line_len + 1 + len(part) > text_width and line:
+ lines.append(indent + ' '.join(line))
+ line = []
+ line_len = len(indent) - 1
+ line.append(part)
+ line_len += len(part) + 1
+ if line:
+ lines.append(indent + ' '.join(line))
+ if prefix is not None:
+ lines[0] = lines[0][len(indent):]
+ return lines
+
+ # if prog is short, follow it with optionals or positionals
+ if len(prefix) + len(prog) <= 0.75 * text_width:
+ indent = ' ' * (len(prefix) + len(prog) + 1)
+ # Begin cmd2 customization
+ if opt_parts:
+ lines = get_lines([prog] + pos_parts, indent, prefix)
+ lines.extend(get_lines(req_parts, indent))
+ lines.extend(get_lines(opt_parts, indent))
+ elif pos_parts:
+ lines = get_lines([prog] + pos_parts, indent, prefix)
+ lines.extend(get_lines(req_parts, indent))
+ else:
+ lines = [prog]
+ # End cmd2 customization
+
+ # if prog is long, put it on its own line
+ else:
+ indent = ' ' * len(prefix)
+ # Begin cmd2 customization
+ parts = pos_parts + req_parts + opt_parts
+ lines = get_lines(parts, indent)
+ if len(lines) > 1:
+ lines = []
+ lines.extend(get_lines(pos_parts, indent))
+ lines.extend(get_lines(req_parts, indent))
+ lines.extend(get_lines(opt_parts, indent))
+ # End cmd2 customization
+ lines = [prog] + lines
+
+ # join lines into usage
+ usage = '\n'.join(lines)
+
+ # prefix with 'usage:'
+ return '%s%s\n\n' % (prefix, usage)
+
+ def _format_action_invocation(self, action):
+ if not action.option_strings:
+ default = self._get_default_metavar_for_positional(action)
+ metavar, = self._metavar_formatter(action, default)(1)
+ return metavar
+
+ else:
+ parts = []
+
+ # if the Optional doesn't take a value, format is:
+ # -s, --long
+ if action.nargs == 0:
+ parts.extend(action.option_strings)
+ return ', '.join(parts)
+
+ # Begin cmd2 customization (less verbose)
+ # if the Optional takes a value, format is:
+ # -s, --long ARGS
+ else:
+ default = self._get_default_metavar_for_optional(action)
+ args_string = self._format_args(action, default)
+
+ return ', '.join(action.option_strings) + ' ' + args_string
+ # End cmd2 customization
+
+ def _metavar_formatter(self, action, default_metavar):
+ if action.metavar is not None:
+ result = action.metavar
+ elif action.choices is not None:
+ choice_strs = [str(choice) for choice in action.choices]
+ # Begin cmd2 customization (added space after comma)
+ result = '{%s}' % ', '.join(choice_strs)
+ # End cmd2 customization
+ else:
+ result = default_metavar
+
+ def format(tuple_size):
+ if isinstance(result, tuple):
+ return result
+ else:
+ return (result, ) * tuple_size
+ return format
+
+ def _format_args(self, action, default_metavar):
+ get_metavar = self._metavar_formatter(action, default_metavar)
+ # Begin cmd2 customization (less verbose)
+ if isinstance(action, _RangeAction) and \
+ action.nargs_min is not None and action.nargs_max is not None:
+ result = '{}{{{}..{}}}'.format('%s' % get_metavar(1), action.nargs_min, action.nargs_max)
+ elif action.nargs == ZERO_OR_MORE:
+ result = '[%s [...]]' % get_metavar(1)
+ elif action.nargs == ONE_OR_MORE:
+ result = '%s [...]' % get_metavar(1)
+ # End cmd2 customization
+ else:
+ result = super()._format_args(action, default_metavar)
+ return result
+
+ def _split_lines(self, text, width):
+ return text.splitlines()
+
+
+class ACArgumentParser(argparse.ArgumentParser):
+ """Custom argparse class to override error method to change default help text."""
+
+ def __init__(self, *args, **kwargs):
+ if 'formatter_class' not in kwargs:
+ kwargs['formatter_class'] = ACHelpFormatter
+
+ super().__init__(*args, **kwargs)
+ register_custom_actions(self)
+
+ self._custom_error_message = ''
+
+ # Begin cmd2 customization
+ def set_custom_message(self, custom_message=''):
+ """
+ Allows an error message override to the error() function, useful when forcing a
+ re-parse of arguments with newly required parameters
+ """
+ self._custom_error_message = custom_message
+ # End cmd2 customization
+
+ def error(self, message):
+ """Custom error override. Allows application to control the error being displayed by argparse"""
+ if len(self._custom_error_message) > 0:
+ message = self._custom_error_message
+ self._custom_error_message = ''
+
+ lines = message.split('\n')
+ linum = 0
+ formatted_message = ''
+ for line in lines:
+ if linum == 0:
+ formatted_message = 'Error: ' + line
+ else:
+ formatted_message += '\n ' + line
+ linum += 1
+
+ sys.stderr.write(Fore.LIGHTRED_EX + '{}\n\n'.format(formatted_message) + Fore.RESET)
+ # sys.stderr.write('{}\n\n'.format(formatted_message))
+ self.print_help()
+ sys.exit(1)
+
+ def format_help(self):
+ """Copy of format_help() from argparse.ArgumentParser with tweaks to separately display required parameters"""
+ formatter = self._get_formatter()
+
+ # usage
+ formatter.add_usage(self.usage, self._actions,
+ self._mutually_exclusive_groups)
+
+ # description
+ formatter.add_text(self.description)
+
+ # Begin cmd2 customization (separate required and optional arguments)
+
+ # positionals, optionals and user-defined groups
+ for action_group in self._action_groups:
+ if action_group.title == 'optional arguments':
+ # check if the arguments are required, group accordingly
+ req_args = []
+ opt_args = []
+ for action in action_group._group_actions:
+ if action.required:
+ req_args.append(action)
+ else:
+ opt_args.append(action)
+
+ # separately display required arguments
+ formatter.start_section('required arguments')
+ formatter.add_text(action_group.description)
+ formatter.add_arguments(req_args)
+ formatter.end_section()
+
+ # now display truly optional arguments
+ formatter.start_section(action_group.title)
+ formatter.add_text(action_group.description)
+ formatter.add_arguments(opt_args)
+ formatter.end_section()
+ else:
+ formatter.start_section(action_group.title)
+ formatter.add_text(action_group.description)
+ formatter.add_arguments(action_group._group_actions)
+ formatter.end_section()
+
+ # End cmd2 customization
+
+ # epilog
+ formatter.add_text(self.epilog)
+
+ # determine help from format above
+ return formatter.format_help()
+
+ def _get_nargs_pattern(self, action):
+ # Override _get_nargs_pattern behavior to use the nargs ranges provided by AutoCompleter
+ if isinstance(action, _RangeAction) and \
+ action.nargs_min is not None and action.nargs_max is not None:
+ nargs_pattern = '(-*A{{{},{}}}-*)'.format(action.nargs_min, action.nargs_max)
+
+ # if this is an optional action, -- is not allowed
+ if action.option_strings:
+ nargs_pattern = nargs_pattern.replace('-*', '')
+ nargs_pattern = nargs_pattern.replace('-', '')
+ return nargs_pattern
+ return super(ACArgumentParser, self)._get_nargs_pattern(action)
+
+ def _match_argument(self, action, arg_strings_pattern):
+ # match the pattern for this action to the arg strings
+ nargs_pattern = self._get_nargs_pattern(action)
+ match = _re.match(nargs_pattern, arg_strings_pattern)
+
+ # raise an exception if we weren't able to find a match
+ if match is None:
+ if isinstance(action, _RangeAction) and \
+ action.nargs_min is not None and action.nargs_max is not None:
+ raise ArgumentError(action,
+ 'Expected between {} and {} arguments'.format(action.nargs_min, action.nargs_max))
+
+ return super(ACArgumentParser, self)._match_argument(action, arg_strings_pattern)
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index b9928a4b..871b356b 100755
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -45,21 +45,38 @@ import traceback
import unittest
from code import InteractiveConsole
-try:
- from enum34 import Enum
-except ImportError:
- from enum import Enum
-
import pyparsing
import pyperclip
+# Set up readline
+from .rl_utils import rl_force_redisplay, readline, rl_type, RlType
+
+if rl_type == RlType.PYREADLINE:
+
+ # Save the original pyreadline display completion function since we need to override it and restore it
+ # noinspection PyProtectedMember
+ orig_pyreadline_display = readline.rl.mode._display_completions
+
+elif rl_type == RlType.GNU:
+
+ # We need wcswidth to calculate display width of tab completions
+ from wcwidth import wcswidth
+
+ # Get the readline lib so we can make changes to it
+ import ctypes
+ from .rl_utils import readline_lib
+
+ # Save address that rl_basic_quote_characters is pointing to since we need to override and restore it
+ rl_basic_quote_characters = ctypes.c_char_p.in_dll(readline_lib, "rl_basic_quote_characters")
+ orig_rl_basic_quote_characters_addr = ctypes.cast(rl_basic_quote_characters, ctypes.c_void_p).value
+
# Newer versions of pyperclip are released as a single file, but older versions had a more complicated structure
try:
from pyperclip.exceptions import PyperclipException
except ImportError:
# noinspection PyUnresolvedReferences
from pyperclip import PyperclipException
-
+
# Collection is a container that is sizable and iterable
# It was introduced in Python 3.6. We will try to import it, otherwise use our implementation
try:
@@ -96,47 +113,6 @@ try:
except ImportError:
ipython_available = False
-# Prefer statically linked gnureadline if available (for macOS compatibility due to issues with libedit)
-try:
- import gnureadline as readline
-except ImportError:
- # Try to import readline, but allow failure for convenience in Windows unit testing
- # Note: If this actually fails, you should install readline on Linux or Mac or pyreadline on Windows
- try:
- # noinspection PyUnresolvedReferences
- import readline
- except ImportError:
- pass
-
-# Check what implementation of readline we are using
-class RlType(Enum):
- GNU = 1
- PYREADLINE = 2
- NONE = 3
-
-rl_type = RlType.NONE
-
-if 'pyreadline' in sys.modules:
- rl_type = RlType.PYREADLINE
-
- # Save the original pyreadline display completion function since we need to override it and restore it
- # noinspection PyProtectedMember
- orig_pyreadline_display = readline.rl.mode._display_completions
-
-elif 'gnureadline' in sys.modules or 'readline' in sys.modules:
- rl_type = RlType.GNU
-
- # We need wcswidth to calculate display width of tab completions
- from wcwidth import wcswidth
-
- # Load the readline lib so we can make changes to it
- import ctypes
- readline_lib = ctypes.CDLL(readline.__file__)
-
- # Save address that rl_basic_quote_characters is pointing to since we need to override and restore it
- rl_basic_quote_characters = ctypes.c_char_p.in_dll(readline_lib, "rl_basic_quote_characters")
- orig_rl_basic_quote_characters_addr = ctypes.cast(rl_basic_quote_characters, ctypes.c_void_p).value
-
__version__ = '0.9.0'
# Pyparsing enablePackrat() can greatly speed up parsing, but problems have been seen in Python 3 in the past
@@ -295,8 +271,18 @@ def with_argparser_and_unknown_args(argparser):
# If there are subcommands, store their names in a list to support tab-completion of subcommand names
if argparser._subparsers is not None:
- subcommand_names = argparser._subparsers._group_actions[0]._name_parser_map.keys()
- cmd_wrapper.__dict__['subcommand_names'] = subcommand_names
+ # Key is subcommand name and value is completer function
+ subcommands = collections.OrderedDict()
+
+ # Get all subcommands and check if they have completer functions
+ for name, parser in argparser._subparsers._group_actions[0]._name_parser_map.items():
+ if 'completer' in parser._defaults:
+ completer = parser._defaults['completer']
+ else:
+ completer = None
+ subcommands[name] = completer
+
+ cmd_wrapper.__dict__['subcommands'] = subcommands
return cmd_wrapper
@@ -1605,7 +1591,7 @@ class Cmd(cmd.Cmd):
return compfunc(text, line, begidx, endidx)
@staticmethod
- def _pad_matches_to_display(matches_to_display):
+ def _pad_matches_to_display(matches_to_display): # pragma: no cover
"""
Adds padding to the matches being displayed as tab completion suggestions.
The default padding of readline/pyreadine is small and not visually appealing
@@ -1627,7 +1613,7 @@ class Cmd(cmd.Cmd):
return [cur_match + padding for cur_match in matches_to_display], len(padding)
- def _display_matches_gnu_readline(self, substitution, matches, longest_match_length):
+ def _display_matches_gnu_readline(self, substitution, matches, longest_match_length): # pragma: no cover
"""
Prints a match list using GNU readline's rl_display_match_list()
This exists to print self.display_matches if it has data. Otherwise matches prints.
@@ -1675,15 +1661,10 @@ class Cmd(cmd.Cmd):
# rl_display_match_list(strings_array, number of completion matches, longest match length)
readline_lib.rl_display_match_list(strings_array, len(encoded_matches), longest_match_length)
- # rl_forced_update_display() is the proper way to redraw the prompt and line, but we
- # have to use ctypes to do it since Python's readline API does not wrap the function
- readline_lib.rl_forced_update_display()
-
- # Since we updated the display, readline asks that rl_display_fixed be set for efficiency
- display_fixed = ctypes.c_int.in_dll(readline_lib, "rl_display_fixed")
- display_fixed.value = 1
+ # Redraw prompt and input line
+ rl_force_redisplay()
- def _display_matches_pyreadline(self, matches):
+ def _display_matches_pyreadline(self, matches): # pragma: no cover
"""
Prints a match list using pyreadline's _display_completions()
This exists to print self.display_matches if it has data. Otherwise matches prints.
@@ -1701,7 +1682,7 @@ class Cmd(cmd.Cmd):
# Add padding for visual appeal
matches_to_display, _ = self._pad_matches_to_display(matches_to_display)
- # Display the matches
+ # Display matches using actual display function. This also redraws the prompt and line.
orig_pyreadline_display(matches_to_display)
# ----- Methods which override stuff in cmd -----
@@ -3363,7 +3344,7 @@ Script should contain one command per line, just like command would be typed in
# self._script_dir list when done.
with open(expanded_path, encoding='utf-8') as target:
self.cmdqueue = target.read().splitlines() + ['eos'] + self.cmdqueue
- except IOError as e:
+ except IOError as e: # pragma: no cover
self.perror('Problem accessing script from {}:\n{}'.format(expanded_path, e))
return
@@ -3390,7 +3371,7 @@ Script should contain one command per line, just like command would be typed in
# noinspection PyUnusedLocal
if sum(1 for line in f) > 0:
valid_text_file = True
- except IOError:
+ except IOError: # pragma: no cover
pass
except UnicodeDecodeError:
# The file is not ASCII. Check if it is UTF-8.
@@ -3400,7 +3381,7 @@ Script should contain one command per line, just like command would be typed in
# noinspection PyUnusedLocal
if sum(1 for line in f) > 0:
valid_text_file = True
- except IOError:
+ except IOError: # pragma: no cover
pass
except UnicodeDecodeError:
# Not UTF-8
@@ -4066,10 +4047,3 @@ class CmdResult(namedtuple_with_two_defaults('CmdResult', ['out', 'err', 'war'])
return not self.err
-if __name__ == '__main__':
- # If run as the main application, simply start a bare-bones cmd2 application with only built-in functionality.
-
- # Set "use_ipython" to True to include the ipy command if IPython is installed, which supports advanced interactive
- # debugging of your application via introspection on self.
- app = Cmd(use_ipython=False)
- app.cmdloop()
diff --git a/cmd2/rl_utils.py b/cmd2/rl_utils.py
new file mode 100644
index 00000000..d5bef1ff
--- /dev/null
+++ b/cmd2/rl_utils.py
@@ -0,0 +1,66 @@
+# coding=utf-8
+"""
+Imports the proper readline for the platform and provides utility functions for it
+"""
+import sys
+
+try:
+ from enum34 import Enum
+except ImportError:
+ from enum import Enum
+
+# Prefer statically linked gnureadline if available (for macOS compatibility due to issues with libedit)
+try:
+ import gnureadline as readline
+except ImportError:
+ # Try to import readline, but allow failure for convenience in Windows unit testing
+ # Note: If this actually fails, you should install readline on Linux or Mac or pyreadline on Windows
+ try:
+ # noinspection PyUnresolvedReferences
+ import readline
+ except ImportError: # pragma: no cover
+ pass
+
+
+class RlType(Enum):
+ """Readline library types we recognize"""
+ GNU = 1
+ PYREADLINE = 2
+ NONE = 3
+
+
+# Check what implementation of readline we are using
+
+rl_type = RlType.NONE
+
+# The order of this check matters since importing pyreadline will also show readline in the modules list
+if 'pyreadline' in sys.modules:
+ rl_type = RlType.PYREADLINE
+
+elif 'gnureadline' in sys.modules or 'readline' in sys.modules:
+ rl_type = RlType.GNU
+
+ # Load the readline lib so we can access members of it
+ import ctypes
+ readline_lib = ctypes.CDLL(readline.__file__)
+
+
+def rl_force_redisplay() -> None:
+ """
+ Causes readline to redraw prompt and input line
+ """
+ if not sys.stdout.isatty():
+ return
+
+ if rl_type == RlType.GNU: # pragma: no cover
+ # rl_forced_update_display() is the proper way to redraw the prompt and line, but we
+ # have to use ctypes to do it since Python's readline API does not wrap the function
+ readline_lib.rl_forced_update_display()
+
+ # After manually updating the display, readline asks that rl_display_fixed be set to 1 for efficiency
+ display_fixed = ctypes.c_int.in_dll(readline_lib, "rl_display_fixed")
+ display_fixed.value = 1
+
+ elif rl_type == RlType.PYREADLINE: # pragma: no cover
+ # noinspection PyProtectedMember
+ readline.rl.mode._print_prompt()
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 8a65d1b9..d22f9341 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,3 +1,4 @@
pyparsing
pyperclip
wcwidth
+colorama
diff --git a/examples/tab_autocompletion.py b/examples/tab_autocompletion.py
new file mode 100755
index 00000000..9741dce2
--- /dev/null
+++ b/examples/tab_autocompletion.py
@@ -0,0 +1,428 @@
+#!/usr/bin/env python
+# coding=utf-8
+"""
+A example usage of the AutoCompleter
+
+Copyright 2018 Eric Lin <anselor@gmail.com>
+Released under MIT license, see LICENSE file
+"""
+import argparse
+import itertools
+from typing import List
+
+import cmd2
+from cmd2 import with_argparser, with_category, argparse_completer
+
+
+class TabCompleteExample(cmd2.Cmd):
+ """ Example cmd2 application where we a base command which has a couple subcommands."""
+
+ CAT_AUTOCOMPLETE = 'AutoComplete Examples'
+
+ def __init__(self):
+ super().__init__()
+
+ # For mocking a data source for the example commands
+ ratings_types = ['G', 'PG', 'PG-13', 'R', 'NC-17']
+ show_ratings = ['TV-Y', 'TV-Y7', 'TV-G', 'TV-PG', 'TV-14', 'TV-MA']
+ static_list_directors = ['J. J. Abrams', 'Irvin Kershner', 'George Lucas', 'Richard Marquand',
+ 'Rian Johnson', 'Gareth Edwards']
+ actors = ['Mark Hamill', 'Harrison Ford', 'Carrie Fisher', 'Alec Guinness', 'Peter Mayhew',
+ 'Anthony Daniels', 'Adam Driver', 'Daisy Ridley', 'John Boyega', 'Oscar Isaac',
+ 'Lupita Nyong\'o', 'Andy Serkis', 'Liam Neeson', 'Ewan McGregor', 'Natalie Portman',
+ 'Jake Lloyd', 'Hayden Christensen', 'Christopher Lee']
+ USER_MOVIE_LIBRARY = ['ROGUE1', 'SW_EP04', 'SW_EP05']
+ MOVIE_DATABASE_IDS = ['SW_EP01', 'SW_EP02', 'SW_EP03', 'ROGUE1', 'SW_EP04',
+ 'SW_EP05', 'SW_EP06', 'SW_EP07', 'SW_EP08', 'SW_EP09']
+ MOVIE_DATABASE = {'SW_EP04': {'title': 'Star Wars: Episode IV - A New Hope',
+ 'rating': 'PG',
+ 'director': ['George Lucas'],
+ 'actor': ['Mark Hamill', 'Harrison Ford', 'Carrie Fisher',
+ 'Alec Guinness', 'Peter Mayhew', 'Anthony Daniels']
+ },
+ 'SW_EP05': {'title': 'Star Wars: Episode V - The Empire Strikes Back',
+ 'rating': 'PG',
+ 'director': ['Irvin Kershner'],
+ 'actor': ['Mark Hamill', 'Harrison Ford', 'Carrie Fisher',
+ 'Alec Guinness', 'Peter Mayhew', 'Anthony Daniels']
+ },
+ 'SW_EP06': {'title': 'Star Wars: Episode IV - A New Hope',
+ 'rating': 'PG',
+ 'director': ['Richard Marquand'],
+ 'actor': ['Mark Hamill', 'Harrison Ford', 'Carrie Fisher',
+ 'Alec Guinness', 'Peter Mayhew', 'Anthony Daniels']
+ },
+ 'SW_EP01': {'title': 'Star Wars: Episode I - The Phantom Menace',
+ 'rating': 'PG',
+ 'director': ['George Lucas'],
+ 'actor': ['Liam Neeson', 'Ewan McGregor', 'Natalie Portman', 'Jake Lloyd']
+ },
+ 'SW_EP02': {'title': 'Star Wars: Episode II - Attack of the Clones',
+ 'rating': 'PG',
+ 'director': ['George Lucas'],
+ 'actor': ['Liam Neeson', 'Ewan McGregor', 'Natalie Portman',
+ 'Hayden Christensen', 'Christopher Lee']
+ },
+ 'SW_EP03': {'title': 'Star Wars: Episode III - Revenge of the Sith',
+ 'rating': 'PG-13',
+ 'director': ['George Lucas'],
+ 'actor': ['Liam Neeson', 'Ewan McGregor', 'Natalie Portman',
+ 'Hayden Christensen']
+ },
+
+ }
+ USER_SHOW_LIBRARY = {'SW_REB': ['S01E01', 'S02E02']}
+ SHOW_DATABASE_IDS = ['SW_CW', 'SW_TCW', 'SW_REB']
+ SHOW_DATABASE = {'SW_CW': {'title': 'Star Wars: Clone Wars',
+ 'rating': 'TV-Y7',
+ 'seasons': {1: ['S01E01', 'S01E02', 'S01E03'],
+ 2: ['S02E01', 'S02E02', 'S02E03']}
+ },
+ 'SW_TCW': {'title': 'Star Wars: The Clone Wars',
+ 'rating': 'TV-PG',
+ 'seasons': {1: ['S01E01', 'S01E02', 'S01E03'],
+ 2: ['S02E01', 'S02E02', 'S02E03']}
+ },
+ 'SW_REB': {'title': 'Star Wars: Rebels',
+ 'rating': 'TV-Y7',
+ 'seasons': {1: ['S01E01', 'S01E02', 'S01E03'],
+ 2: ['S02E01', 'S02E02', 'S02E03']}
+ },
+ }
+
+ # This demonstrates a number of customizations of the AutoCompleter version of ArgumentParser
+ # - The help output will separately group required vs optional flags
+ # - The help output for arguments with multiple flags or with append=True is more concise
+ # - ACArgumentParser adds the ability to specify ranges of argument counts in 'nargs'
+
+ suggest_parser = argparse_completer.ACArgumentParser()
+
+ suggest_parser.add_argument('-t', '--type', choices=['movie', 'show'], required=True)
+ suggest_parser.add_argument('-d', '--duration', nargs=(1, 2), action='append',
+ help='Duration constraint in minutes.\n'
+ '\tsingle value - maximum duration\n'
+ '\t[a, b] - duration range')
+
+ @with_category(CAT_AUTOCOMPLETE)
+ @with_argparser(suggest_parser)
+ def do_suggest(self, args) -> None:
+ """Suggest command demonstrates argparse customizations
+
+ See hybrid_suggest and orig_suggest to compare the help output.
+
+
+ """
+ if not args.type:
+ self.do_help('suggest')
+
+ def complete_suggest(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
+ """ Adds tab completion to media"""
+ completer = argparse_completer.AutoCompleter(TabCompleteExample.suggest_parser, 1)
+
+ tokens, _ = self.tokens_for_completion(line, begidx, endidx)
+ results = completer.complete_command(tokens, text, line, begidx, endidx)
+
+ return results
+
+ # If you prefer the original argparse help output but would like narg ranges, it's possible
+ # to enable narg ranges without the help changes using this method
+
+ suggest_parser_hybrid = argparse.ArgumentParser()
+ # This registers the custom narg range handling
+ argparse_completer.register_custom_actions(suggest_parser_hybrid)
+
+ suggest_parser_hybrid.add_argument('-t', '--type', choices=['movie', 'show'], required=True)
+ suggest_parser_hybrid.add_argument('-d', '--duration', nargs=(1, 2), action='append',
+ help='Duration constraint in minutes.\n'
+ '\tsingle value - maximum duration\n'
+ '\t[a, b] - duration range')
+
+ @with_category(CAT_AUTOCOMPLETE)
+ @with_argparser(suggest_parser_hybrid)
+ def do_hybrid_suggest(self, args):
+ if not args.type:
+ self.do_help('orig_suggest')
+
+ def complete_hybrid_suggest(self, text, line, begidx, endidx):
+ """ Adds tab completion to media"""
+ completer = argparse_completer.AutoCompleter(TabCompleteExample.suggest_parser_hybrid)
+
+ tokens, _ = self.tokens_for_completion(line, begidx, endidx)
+ results = completer.complete_command(tokens, text, line, begidx, endidx)
+
+ return results
+
+ # This variant demonstrates the AutoCompleter working with the orginial argparse.
+ # Base argparse is unable to specify narg ranges. Autocompleter will keep expecting additional arguments
+ # for the -d/--duration flag until you specify a new flaw or end the list it with '--'
+
+ suggest_parser_orig = argparse.ArgumentParser()
+
+ suggest_parser_orig.add_argument('-t', '--type', choices=['movie', 'show'], required=True)
+ suggest_parser_orig.add_argument('-d', '--duration', nargs='+', action='append',
+ help='Duration constraint in minutes.\n'
+ '\tsingle value - maximum duration\n'
+ '\t[a, b] - duration range')
+
+ @with_argparser(suggest_parser_orig)
+ @with_category(CAT_AUTOCOMPLETE)
+ def do_orig_suggest(self, args) -> None:
+ if not args.type:
+ self.do_help('orig_suggest')
+
+ def complete_orig_suggest(self, text, line, begidx, endidx) -> List[str]:
+ """ Adds tab completion to media"""
+ completer = argparse_completer.AutoCompleter(TabCompleteExample.suggest_parser_orig)
+
+ tokens, _ = self.tokens_for_completion(line, begidx, endidx)
+ results = completer.complete_command(tokens, text, line, begidx, endidx)
+
+ return results
+
+ ###################################################################################
+ # The media command demonstrates a completer with multiple layers of subcommands
+ # - This example uses a flat completion lookup dictionary
+
+ def query_actors(self) -> List[str]:
+ """Simulating a function that queries and returns a completion values"""
+ return TabCompleteExample.actors
+
+ def _do_media_movies(self, args) -> None:
+ if not args.command:
+ self.do_help('media movies')
+ elif args.command == 'list':
+ for movie_id in TabCompleteExample.MOVIE_DATABASE:
+ movie = TabCompleteExample.MOVIE_DATABASE[movie_id]
+ print('{}\n-----------------------------\n{} ID: {}\nDirector: {}\nCast:\n {}\n\n'
+ .format(movie['title'], movie['rating'], movie_id,
+ ', '.join(movie['director']),
+ '\n '.join(movie['actor'])))
+
+ def _do_media_shows(self, args) -> None:
+ if not args.command:
+ self.do_help('media shows')
+
+ elif args.command == 'list':
+ for show_id in TabCompleteExample.SHOW_DATABASE:
+ show = TabCompleteExample.SHOW_DATABASE[show_id]
+ print('{}\n-----------------------------\n{} ID: {}'
+ .format(show['title'], show['rating'], show_id))
+ for season in show['seasons']:
+ ep_list = show['seasons'][season]
+ print(' Season {}:\n {}'
+ .format(season,
+ '\n '.join(ep_list)))
+ print()
+
+
+ media_parser = argparse_completer.ACArgumentParser(prog='media')
+
+ media_types_subparsers = media_parser.add_subparsers(title='Media Types', dest='type')
+
+ movies_parser = media_types_subparsers.add_parser('movies')
+ movies_parser.set_defaults(func=_do_media_movies)
+
+ movies_commands_subparsers = movies_parser.add_subparsers(title='Commands', dest='command')
+
+ movies_list_parser = movies_commands_subparsers.add_parser('list')
+
+ movies_list_parser.add_argument('-t', '--title', help='Title Filter')
+ movies_list_parser.add_argument('-r', '--rating', help='Rating Filter', nargs='+',
+ choices=ratings_types)
+ movies_list_parser.add_argument('-d', '--director', help='Director Filter')
+ movies_list_parser.add_argument('-a', '--actor', help='Actor Filter', action='append')
+
+ movies_add_parser = movies_commands_subparsers.add_parser('add')
+ movies_add_parser.add_argument('title', help='Movie Title')
+ movies_add_parser.add_argument('rating', help='Movie Rating', choices=ratings_types)
+ movies_add_parser.add_argument('-d', '--director', help='Director', nargs=(1, 2), required=True)
+ movies_add_parser.add_argument('actor', help='Actors', nargs='*')
+
+ movies_delete_parser = movies_commands_subparsers.add_parser('delete')
+
+ shows_parser = media_types_subparsers.add_parser('shows')
+ shows_parser.set_defaults(func=_do_media_shows)
+
+ shows_commands_subparsers = shows_parser.add_subparsers(title='Commands', dest='command')
+
+ shows_list_parser = shows_commands_subparsers.add_parser('list')
+
+ @with_category(CAT_AUTOCOMPLETE)
+ @with_argparser(media_parser)
+ def do_media(self, args):
+ """Media management command demonstrates multiple layers of subcommands being handled by AutoCompleter"""
+ func = getattr(args, 'func', None)
+ if func is not None:
+ # Call whatever subcommand function was selected
+ func(self, args)
+ else:
+ # No subcommand was provided, so call help
+ self.do_help('media')
+
+ # This completer is implemented using a single dictionary to look up completion lists for all layers of
+ # subcommands. For each argument, AutoCompleter will search for completion values from the provided
+ # arg_choices dict. This requires careful naming of argparse arguments so that there are no unintentional
+ # name collisions.
+ def complete_media(self, text, line, begidx, endidx):
+ """ Adds tab completion to media"""
+ choices = {'actor': self.query_actors, # function
+ 'director': TabCompleteExample.static_list_directors # static list
+ }
+ completer = argparse_completer.AutoCompleter(TabCompleteExample.media_parser, arg_choices=choices)
+
+ tokens, _ = self.tokens_for_completion(line, begidx, endidx)
+ results = completer.complete_command(tokens, text, line, begidx, endidx)
+
+ return results
+
+ ###################################################################################
+ # The library command demonstrates a completer with multiple layers of subcommands
+ # with different completion results per sub-command
+ # - This demonstrates how to build a tree of completion lookups to pass down
+ #
+ # Only use this method if you absolutely need to as it dramatically
+ # increases the complexity and decreases readability.
+
+ def _do_library_movie(self, args):
+ if not args.type or not args.command:
+ self.do_help('library movie')
+
+ def _do_library_show(self, args):
+ if not args.type:
+ self.do_help('library show')
+
+ def _query_movie_database(self):
+ return list(set(TabCompleteExample.MOVIE_DATABASE_IDS).difference(set(TabCompleteExample.USER_MOVIE_LIBRARY)))
+
+ def _query_movie_user_library(self):
+ return TabCompleteExample.USER_MOVIE_LIBRARY
+
+ def _filter_library(self, text, line, begidx, endidx, full, exclude=()):
+ candidates = list(set(full).difference(set(exclude)))
+ return [entry for entry in candidates if entry.startswith(text)]
+
+ library_parser = argparse_completer.ACArgumentParser(prog='library')
+
+ library_subcommands = library_parser.add_subparsers(title='Media Types', dest='type')
+
+ library_movie_parser = library_subcommands.add_parser('movie')
+ library_movie_parser.set_defaults(func=_do_library_movie)
+
+ library_movie_subcommands = library_movie_parser.add_subparsers(title='Command', dest='command')
+
+ library_movie_add_parser = library_movie_subcommands.add_parser('add')
+ library_movie_add_parser.add_argument('movie_id', help='ID of movie to add', action='append')
+ library_movie_add_parser.add_argument('-b', '--borrowed', action='store_true')
+
+ library_movie_remove_parser = library_movie_subcommands.add_parser('remove')
+ library_movie_remove_parser.add_argument('movie_id', help='ID of movie to remove', action='append')
+
+ library_show_parser = library_subcommands.add_parser('show')
+ library_show_parser.set_defaults(func=_do_library_show)
+
+ library_show_subcommands = library_show_parser.add_subparsers(title='Command', dest='command')
+
+ library_show_add_parser = library_show_subcommands.add_parser('add')
+ library_show_add_parser.add_argument('show_id', help='Show IDs to add')
+ library_show_add_parser.add_argument('episode_id', nargs='*', help='Show IDs to add')
+
+ library_show_rmv_parser = library_show_subcommands.add_parser('remove')
+
+ # Demonstrates a custom completion function that does more with the command line than is
+ # allowed by the standard completion functions
+ def _filter_episodes(self, text, line, begidx, endidx, show_db, user_lib):
+ tokens, _ = self.tokens_for_completion(line, begidx, endidx)
+ show_id = tokens[3]
+ if show_id:
+ if show_id in show_db:
+ show = show_db[show_id]
+ all_episodes = itertools.chain(*(show['seasons'].values()))
+
+ if show_id in user_lib:
+ user_eps = user_lib[show_id]
+ else:
+ user_eps = []
+
+ return self._filter_library(text, line, begidx, endidx, all_episodes, user_eps)
+ return []
+
+ @with_category(CAT_AUTOCOMPLETE)
+ @with_argparser(library_parser)
+ def do_library(self, args):
+ """Media management command demonstrates multiple layers of subcommands being handled by AutoCompleter"""
+ func = getattr(args, 'func', None)
+ if func is not None:
+ # Call whatever subcommand function was selected
+ func(self, args)
+ else:
+ # No subcommand was provided, so call help
+ self.do_help('library')
+
+ def complete_library(self, text, line, begidx, endidx):
+
+ # this demonstrates the much more complicated scenario of having
+ # unique completion parameters per sub-command that use the same
+ # argument name. To do this we build a multi-layer nested tree
+ # of lookups far AutoCompleter to traverse. This nested tree must
+ # match the structure of the argparse parser
+ #
+
+ movie_add_choices = {'movie_id': self._query_movie_database}
+ movie_remove_choices = {'movie_id': self._query_movie_user_library}
+
+ # This demonstrates the ability to mix custom completion functions with argparse completion.
+ # By specifying a tuple for a completer, AutoCompleter expects a custom completion function
+ # with optional index-based as well as keyword based arguments. This is an alternative to using
+ # a partial function.
+
+ show_add_choices = {'show_id': (self._filter_library, # This is a custom completion function
+ # This tuple represents index-based args to append to the function call
+ (list(TabCompleteExample.SHOW_DATABASE.keys()),)
+ ),
+ 'episode_id': (self._filter_episodes, # this is a custom completion function
+ # this list represents index-based args to append to the function call
+ [TabCompleteExample.SHOW_DATABASE],
+ # this dict contains keyword-based args to append to the function call
+ {'user_lib': TabCompleteExample.USER_SHOW_LIBRARY})}
+ show_remove_choices = {}
+
+ # The library movie sub-parser group 'command' has 2 sub-parsers:
+ # 'add' and 'remove'
+ library_movie_command_params = \
+ {'add': (movie_add_choices, None),
+ 'remove': (movie_remove_choices, None)}
+
+ library_show_command_params = \
+ {'add': (show_add_choices, None),
+ 'remove': (show_remove_choices, None)}
+
+ # The 'library movie' command has a sub-parser group called 'command'
+ library_movie_subcommand_groups = {'command': library_movie_command_params}
+ library_show_subcommand_groups = {'command': library_show_command_params}
+
+ # Mapping of a specific sub-parser of the 'type' group to a tuple. Each
+ # tuple has 2 values corresponding what's passed to the constructor
+ # parameters (arg_choices,subcmd_args_lookup) of the nested
+ # instance of AutoCompleter
+ library_type_params = {'movie': (None, library_movie_subcommand_groups),
+ 'show': (None, library_show_subcommand_groups)}
+
+ # maps the a subcommand group to a dictionary mapping a specific
+ # sub-command to a tuple of (arg_choices, subcmd_args_lookup)
+ #
+ # In this example, 'library_parser' has a sub-parser group called 'type'
+ # under the type sub-parser group, there are 2 sub-parsers: 'movie', 'show'
+ library_subcommand_groups = {'type': library_type_params}
+
+ completer = argparse_completer.AutoCompleter(TabCompleteExample.library_parser,
+ subcmd_args_lookup=library_subcommand_groups)
+
+ tokens, _ = self.tokens_for_completion(line, begidx, endidx)
+ results = completer.complete_command(tokens, text, line, begidx, endidx)
+
+ return results
+
+
+if __name__ == '__main__':
+ app = TabCompleteExample()
+ app.cmdloop()
diff --git a/main.py b/main.py
new file mode 100755
index 00000000..9e340600
--- /dev/null
+++ b/main.py
@@ -0,0 +1,12 @@
+#!/usr/bin/env python
+# coding=utf-8
+
+from cmd2 import Cmd
+
+if __name__ == '__main__':
+ # If run as the main application, simply start a bare-bones cmd2 application with only built-in functionality.
+
+ # Set "use_ipython" to True to include the ipy command if IPython is installed, which supports advanced interactive
+ # debugging of your application via introspection on self.
+ app = Cmd(use_ipython=True)
+ app.cmdloop()
diff --git a/setup.py b/setup.py
index d925ccc2..3020cf24 100755
--- a/setup.py
+++ b/setup.py
@@ -61,7 +61,7 @@ Programming Language :: Python :: Implementation :: PyPy3
Topic :: Software Development :: Libraries :: Python Modules
""".splitlines())))
-INSTALL_REQUIRES = ['pyparsing >= 2.1.0', 'pyperclip >= 1.5.27']
+INSTALL_REQUIRES = ['pyparsing >= 2.1.0', 'pyperclip >= 1.5.27', 'colorama']
EXTRAS_REQUIRE = {
# Windows also requires pyreadline to ensure tab completion works
@@ -69,7 +69,7 @@ EXTRAS_REQUIRE = {
# POSIX OSes also require wcwidth for correctly estimating the displayed width of unicode chars
":sys_platform!='win32'": ['wcwidth'],
# Python 3.4 and earlier require contextlib2 for temporarily redirecting stderr and stdout
- ":python_version<'3.5'": ['contextlib2'],
+ ":python_version<'3.5'": ['contextlib2', 'typing'],
}
if int(setuptools.__version__.split('.')[0]) < 18:
@@ -80,6 +80,7 @@ if int(setuptools.__version__.split('.')[0]) < 18:
INSTALL_REQUIRES.append('wcwidth')
if sys.version_info < (3, 5):
INSTALL_REQUIRES.append('contextlib2')
+ INSTALL_REQUIRES.append('typing')
TESTS_REQUIRE = ['pytest', 'pytest-xdist']
DOCS_REQUIRE = ['sphinx', 'sphinx_rtd_theme', 'pyparsing', 'pyperclip', 'wcwidth']
diff --git a/tests/conftest.py b/tests/conftest.py
index 837e7504..ed76cba9 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -8,9 +8,21 @@ Released under MIT license, see LICENSE file
import sys
from pytest import fixture
+from unittest import mock
import cmd2
+# Prefer statically linked gnureadline if available (for macOS compatibility due to issues with libedit)
+try:
+ import gnureadline as readline
+except ImportError:
+ # Try to import readline, but allow failure for convenience in Windows unit testing
+ # Note: If this actually fails, you should install readline on Linux or Mac or pyreadline on Windows
+ try:
+ # noinspection PyUnresolvedReferences
+ import readline
+ except ImportError:
+ pass
# Help text for base cmd2.Cmd application
BASE_HELP = """Documented commands (type help <topic>):
@@ -141,3 +153,38 @@ def base_app():
c = cmd2.Cmd()
c.stdout = StdOut()
return c
+
+
+def complete_tester(text, line, begidx, endidx, app):
+ """
+ This is a convenience function to test cmd2.complete() since
+ in a unit test environment there is no actual console readline
+ is monitoring. Therefore we use mock to provide readline data
+ to complete().
+
+ :param text: str - the string prefix we are attempting to match
+ :param line: str - the current input line with leading whitespace removed
+ :param begidx: int - the beginning index of the prefix text
+ :param endidx: int - the ending index of the prefix text
+ :param app: the cmd2 app that will run completions
+ :return: The first matched string or None if there are no matches
+ Matches are stored in app.completion_matches
+ These matches also have been sorted by complete()
+ """
+ def get_line():
+ return line
+
+ def get_begidx():
+ return begidx
+
+ def get_endidx():
+ return endidx
+
+ first_match = None
+ with mock.patch.object(readline, 'get_line_buffer', get_line):
+ with mock.patch.object(readline, 'get_begidx', get_begidx):
+ with mock.patch.object(readline, 'get_endidx', get_endidx):
+ # Run the readline tab-completion function with readline mocks in place
+ first_match = app.complete(text, 0)
+
+ return first_match
diff --git a/tests/test_acargparse.py b/tests/test_acargparse.py
new file mode 100644
index 00000000..be3e8b97
--- /dev/null
+++ b/tests/test_acargparse.py
@@ -0,0 +1,53 @@
+"""
+Unit/functional testing for argparse customizations in cmd2
+
+Copyright 2018 Eric Lin <anselor@gmail.com>
+Released under MIT license, see LICENSE file
+"""
+import pytest
+from cmd2.argparse_completer import ACArgumentParser
+
+
+def test_acarg_narg_empty_tuple():
+ with pytest.raises(ValueError) as excinfo:
+ parser = ACArgumentParser(prog='test')
+ parser.add_argument('invalid_tuple', nargs=())
+ assert 'Ranged values for nargs must be a tuple of 2 integers' in str(excinfo.value)
+
+
+def test_acarg_narg_single_tuple():
+ with pytest.raises(ValueError) as excinfo:
+ parser = ACArgumentParser(prog='test')
+ parser.add_argument('invalid_tuple', nargs=(1,))
+ assert 'Ranged values for nargs must be a tuple of 2 integers' in str(excinfo.value)
+
+
+def test_acarg_narg_tuple_triple():
+ with pytest.raises(ValueError) as excinfo:
+ parser = ACArgumentParser(prog='test')
+ parser.add_argument('invalid_tuple', nargs=(1, 2, 3))
+ assert 'Ranged values for nargs must be a tuple of 2 integers' in str(excinfo.value)
+
+
+def test_acarg_narg_tuple_order():
+ with pytest.raises(ValueError) as excinfo:
+ parser = ACArgumentParser(prog='test')
+ parser.add_argument('invalid_tuple', nargs=(2, 1))
+ assert 'Invalid nargs range. The first value must be less than the second' in str(excinfo.value)
+
+
+def test_acarg_narg_tuple_negative():
+ with pytest.raises(ValueError) as excinfo:
+ parser = ACArgumentParser(prog='test')
+ parser.add_argument('invalid_tuple', nargs=(-1, 1))
+ assert 'Negative numbers are invalid for nargs range' in str(excinfo.value)
+
+
+def test_acarg_narg_tuple_zero_base():
+ parser = ACArgumentParser(prog='test')
+ parser.add_argument('tuple', nargs=(0, 3))
+
+
+def test_acarg_narg_tuple_zero_to_one():
+ parser = ACArgumentParser(prog='test')
+ parser.add_argument('tuple', nargs=(0, 1))
diff --git a/tests/test_autocompletion.py b/tests/test_autocompletion.py
new file mode 100644
index 00000000..e68bc104
--- /dev/null
+++ b/tests/test_autocompletion.py
@@ -0,0 +1,256 @@
+"""
+Unit/functional testing for argparse completer in cmd2
+
+Copyright 2018 Eric Lin <anselor@gmail.com>
+Released under MIT license, see LICENSE file
+"""
+import pytest
+from .conftest import run_cmd, normalize, StdOut, complete_tester
+
+from examples.tab_autocompletion import TabCompleteExample
+
+@pytest.fixture
+def cmd2_app():
+ c = TabCompleteExample()
+ c.stdout = StdOut()
+
+ return c
+
+
+SUGGEST_HELP = '''Usage: suggest -t {movie, show} [-h] [-d DURATION{1..2}]
+
+Suggest command demonstrates argparse customizations See hybrid_suggest and
+orig_suggest to compare the help output.
+
+required arguments:
+ -t, --type {movie, show}
+
+optional arguments:
+ -h, --help show this help message and exit
+ -d, --duration DURATION{1..2}
+ Duration constraint in minutes.
+ single value - maximum duration
+ [a, b] - duration range'''
+
+MEDIA_MOVIES_ADD_HELP = '''Usage: media movies add title {G, PG, PG-13, R, NC-17} [actor [...]]
+ -d DIRECTOR{1..2}
+ [-h]
+
+positional arguments:
+ title Movie Title
+ {G, PG, PG-13, R, NC-17}
+ Movie Rating
+ actor Actors
+
+required arguments:
+ -d, --director DIRECTOR{1..2}
+ Director
+
+optional arguments:
+ -h, --help show this help message and exit'''
+
+def test_help_required_group(cmd2_app, capsys):
+ run_cmd(cmd2_app, 'suggest -h')
+ out, err = capsys.readouterr()
+ out1 = normalize(str(out))
+
+ out2 = run_cmd(cmd2_app, 'help suggest')
+
+ assert out1 == out2
+ assert out1[0].startswith('Usage: suggest')
+ assert out1[1] == ''
+ assert out1[2].startswith('Suggest command demonstrates argparse customizations ')
+ assert out1 == normalize(SUGGEST_HELP)
+
+
+def test_help_required_group_long(cmd2_app, capsys):
+ run_cmd(cmd2_app, 'media movies add -h')
+ out, err = capsys.readouterr()
+ out1 = normalize(str(out))
+
+ out2 = run_cmd(cmd2_app, 'help media movies add')
+
+ assert out1 == out2
+ assert out1[0].startswith('Usage: media movies add')
+ assert out1 == normalize(MEDIA_MOVIES_ADD_HELP)
+
+
+def test_autocomp_flags(cmd2_app):
+ text = '-'
+ line = 'suggest {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ assert first_match is not None and \
+ cmd2_app.completion_matches == ['--duration', '--help', '--type', '-d', '-h', '-t']
+
+def test_autcomp_hint(cmd2_app, capsys):
+ text = ''
+ line = 'suggest -d {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ out, err = capsys.readouterr()
+
+ assert out == '''
+Hint:
+ -d, --duration DURATION Duration constraint in minutes.
+ single value - maximum duration
+ [a, b] - duration range
+
+'''
+
+def test_autcomp_flag_comp(cmd2_app, capsys):
+ text = '--d'
+ line = 'suggest {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ out, err = capsys.readouterr()
+
+ assert first_match is not None and \
+ cmd2_app.completion_matches == ['--duration ']
+
+
+def test_autocomp_flags_choices(cmd2_app):
+ text = ''
+ line = 'suggest -t {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ assert first_match is not None and \
+ cmd2_app.completion_matches == ['movie', 'show']
+
+
+def test_autcomp_hint_in_narg_range(cmd2_app, capsys):
+ text = ''
+ line = 'suggest -d 2 {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ out, err = capsys.readouterr()
+
+ assert out == '''
+Hint:
+ -d, --duration DURATION Duration constraint in minutes.
+ single value - maximum duration
+ [a, b] - duration range
+
+'''
+
+def test_autocomp_flags_narg_max(cmd2_app):
+ text = ''
+ line = 'suggest d 2 3 {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ assert first_match is None
+
+
+def test_autcomp_narg_beyond_max(cmd2_app, capsys):
+ run_cmd(cmd2_app, 'suggest -t movie -d 3 4 5')
+ out, err = capsys.readouterr()
+
+ assert 'Error: unrecognized arguments: 5' in err
+
+
+def test_autocomp_subcmd_nested(cmd2_app):
+ text = ''
+ line = 'media movies {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ assert first_match is not None and \
+ cmd2_app.completion_matches == ['add', 'delete', 'list']
+
+
+def test_autocomp_subcmd_flag_choices_append(cmd2_app):
+ text = ''
+ line = 'media movies list -r {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ assert first_match is not None and \
+ cmd2_app.completion_matches == ['G', 'NC-17', 'PG', 'PG-13', 'R']
+
+def test_autocomp_subcmd_flag_choices_append_exclude(cmd2_app):
+ text = ''
+ line = 'media movies list -r PG PG-13 {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ assert first_match is not None and \
+ cmd2_app.completion_matches == ['G', 'NC-17', 'R']
+
+
+def test_autocomp_subcmd_flag_comp_func(cmd2_app):
+ text = 'A'
+ line = 'media movies list -a "{}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ assert first_match is not None and \
+ cmd2_app.completion_matches == ['Adam Driver', 'Alec Guinness', 'Andy Serkis', 'Anthony Daniels']
+
+
+def test_autocomp_subcmd_flag_comp_list(cmd2_app):
+ text = 'G'
+ line = 'media movies list -d {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ assert first_match is not None and first_match == '"Gareth Edwards'
+
+
+def test_autcomp_pos_consumed(cmd2_app):
+ text = ''
+ line = 'library movie add SW_EP01 {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ assert first_match is None
+
+
+def test_autcomp_pos_after_flag(cmd2_app):
+ text = 'Joh'
+ line = 'media movies add -d "George Lucas" -- "Han Solo" PG "Emilia Clarke" "{}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ assert first_match is not None and \
+ cmd2_app.completion_matches == ['John Boyega" ']
+
+
+def test_autcomp_custom_func_list_arg(cmd2_app):
+ text = 'SW_'
+ line = 'library show add {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ assert first_match is not None and \
+ cmd2_app.completion_matches == ['SW_CW', 'SW_REB', 'SW_TCW']
+
+
+def test_autcomp_custom_func_list_and_dict_arg(cmd2_app):
+ text = ''
+ line = 'library show add SW_REB {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ assert first_match is not None and \
+ cmd2_app.completion_matches == ['S01E02', 'S01E03', 'S02E01', 'S02E03']
diff --git a/tests/test_completion.py b/tests/test_completion.py
index 5e76aee6..a01d1166 100644
--- a/tests/test_completion.py
+++ b/tests/test_completion.py
@@ -13,21 +13,8 @@ import os
import sys
import cmd2
-from unittest import mock
import pytest
-
-# Prefer statically linked gnureadline if available (for macOS compatibility due to issues with libedit)
-try:
- import gnureadline as readline
-except ImportError:
- # Try to import readline, but allow failure for convenience in Windows unit testing
- # Note: If this actually fails, you should install readline on Linux or Mac or pyreadline on Windows
- try:
- # noinspection PyUnresolvedReferences
- import readline
- except ImportError:
- pass
-
+from .conftest import complete_tester
# List of strings used with completion functions
food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato']
@@ -87,41 +74,6 @@ def cmd2_app():
return c
-def complete_tester(text, line, begidx, endidx, app):
- """
- This is a convenience function to test cmd2.complete() since
- in a unit test environment there is no actual console readline
- is monitoring. Therefore we use mock to provide readline data
- to complete().
-
- :param text: str - the string prefix we are attempting to match
- :param line: str - the current input line with leading whitespace removed
- :param begidx: int - the beginning index of the prefix text
- :param endidx: int - the ending index of the prefix text
- :param app: the cmd2 app that will run completions
- :return: The first matched string or None if there are no matches
- Matches are stored in app.completion_matches
- These matches also have been sorted by complete()
- """
- def get_line():
- return line
-
- def get_begidx():
- return begidx
-
- def get_endidx():
- return endidx
-
- first_match = None
- with mock.patch.object(readline, 'get_line_buffer', get_line):
- with mock.patch.object(readline, 'get_begidx', get_begidx):
- with mock.patch.object(readline, 'get_endidx', get_endidx):
- # Run the readline tab-completion function with readline mocks in place
- first_match = app.complete(text, 0)
-
- return first_match
-
-
def test_cmd2_command_completion_single(cmd2_app):
text = 'he'
line = text
@@ -911,6 +863,7 @@ def test_subcommand_tab_completion(sc_app):
# It is at end of line, so extra space is present
assert first_match is not None and sc_app.completion_matches == ['Football ']
+
def test_subcommand_tab_completion_with_no_completer(sc_app):
# This tests what happens when a subcommand has no completer
# In this case, the foo subcommand has no completer defined
@@ -922,6 +875,7 @@ def test_subcommand_tab_completion_with_no_completer(sc_app):
first_match = complete_tester(text, line, begidx, endidx, sc_app)
assert first_match is None
+
def test_subcommand_tab_completion_space_in_text(sc_app):
text = 'B'
line = 'base sport "Space {}'.format(text)
@@ -934,6 +888,179 @@ def test_subcommand_tab_completion_space_in_text(sc_app):
sc_app.completion_matches == ['Ball" '] and \
sc_app.display_matches == ['Space Ball']
+####################################################
+
+
+class SubcommandsWithUnknownExample(cmd2.Cmd):
+ """
+ Example cmd2 application where we a base command which has a couple subcommands
+ and the "sport" subcommand has tab completion enabled.
+ """
+
+ def __init__(self):
+ cmd2.Cmd.__init__(self)
+
+ # subcommand functions for the base command
+ def base_foo(self, args):
+ """foo subcommand of base command"""
+ self.poutput(args.x * args.y)
+
+ def base_bar(self, args):
+ """bar subcommand of base command"""
+ self.poutput('((%s))' % args.z)
+
+ def base_sport(self, args):
+ """sport subcommand of base command"""
+ self.poutput('Sport is {}'.format(args.sport))
+
+ # noinspection PyUnusedLocal
+ def complete_base_sport(self, text, line, begidx, endidx):
+ """ Adds tab completion to base sport subcommand """
+ index_dict = {1: sport_item_strs}
+ return self.index_based_complete(text, line, begidx, endidx, index_dict)
+
+ # create the top-level parser for the base command
+ base_parser = argparse.ArgumentParser(prog='base')
+ base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help')
+
+ # create the parser for the "foo" subcommand
+ parser_foo = base_subparsers.add_parser('foo', help='foo help')
+ parser_foo.add_argument('-x', type=int, default=1, help='integer')
+ parser_foo.add_argument('y', type=float, help='float')
+ parser_foo.set_defaults(func=base_foo)
+
+ # create the parser for the "bar" subcommand
+ parser_bar = base_subparsers.add_parser('bar', help='bar help')
+ parser_bar.add_argument('z', help='string')
+ parser_bar.set_defaults(func=base_bar)
+
+ # create the parser for the "sport" subcommand
+ parser_sport = base_subparsers.add_parser('sport', help='sport help')
+ parser_sport.add_argument('sport', help='Enter name of a sport')
+
+ # Set both a function and tab completer for the "sport" subcommand
+ parser_sport.set_defaults(func=base_sport, completer=complete_base_sport)
+
+ @cmd2.with_argparser_and_unknown_args(base_parser)
+ def do_base(self, args):
+ """Base command help"""
+ func = getattr(args, 'func', None)
+ if func is not None:
+ # Call whatever subcommand function was selected
+ func(self, args)
+ else:
+ # No subcommand was provided, so call help
+ self.do_help('base')
+
+ # Enable tab completion of base to make sure the subcommands' completers get called.
+ complete_base = cmd2.Cmd.cmd_with_subs_completer
+
+
+@pytest.fixture
+def scu_app():
+ """Declare test fixture for with_argparser_and_unknown_args"""
+ app = SubcommandsWithUnknownExample()
+ return app
+
+
+def test_cmd2_subcmd_with_unknown_completion_single_end(scu_app):
+ text = 'f'
+ line = 'base {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, scu_app)
+
+ # It is at end of line, so extra space is present
+ assert first_match is not None and scu_app.completion_matches == ['foo ']
+
+
+def test_cmd2_subcmd_with_unknown_completion_multiple(scu_app):
+ text = ''
+ line = 'base {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, scu_app)
+ assert first_match is not None and scu_app.completion_matches == ['bar', 'foo', 'sport']
+
+
+def test_cmd2_subcmd_with_unknown_completion_nomatch(scu_app):
+ text = 'z'
+ line = 'base {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, scu_app)
+ assert first_match is None
+
+
+def test_cmd2_help_subcommand_completion_single(scu_app):
+ text = 'base'
+ line = 'help {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+ assert scu_app.complete_help(text, line, begidx, endidx) == ['base']
+
+
+def test_cmd2_help_subcommand_completion_multiple(scu_app):
+ text = ''
+ line = 'help base {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ matches = sorted(scu_app.complete_help(text, line, begidx, endidx))
+ assert matches == ['bar', 'foo', 'sport']
+
+
+def test_cmd2_help_subcommand_completion_nomatch(scu_app):
+ text = 'z'
+ line = 'help base {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+ assert scu_app.complete_help(text, line, begidx, endidx) == []
+
+
+def test_subcommand_tab_completion(scu_app):
+ # This makes sure the correct completer for the sport subcommand is called
+ text = 'Foot'
+ line = 'base sport {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, scu_app)
+
+ # It is at end of line, so extra space is present
+ assert first_match is not None and scu_app.completion_matches == ['Football ']
+
+
+def test_subcommand_tab_completion_with_no_completer(scu_app):
+ # This tests what happens when a subcommand has no completer
+ # In this case, the foo subcommand has no completer defined
+ text = 'Foot'
+ line = 'base foo {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, scu_app)
+ assert first_match is None
+
+
+def test_subcommand_tab_completion_space_in_text(scu_app):
+ text = 'B'
+ line = 'base sport "Space {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, scu_app)
+
+ assert first_match is not None and \
+ scu_app.completion_matches == ['Ball" '] and \
+ scu_app.display_matches == ['Space Ball']
+
+####################################################
+
+
class SecondLevel(cmd2.Cmd):
"""To be used as a second level command class. """
diff --git a/tox.ini b/tox.ini
index f68c1cb8..6749418b 100644
--- a/tox.ini
+++ b/tox.ini
@@ -20,7 +20,7 @@ deps =
pytest-xdist
wcwidth
commands =
- py.test {posargs: -n 2} --cov=cmd2 --cov-report=term-missing --forked
+ py.test {posargs: -n 2} --cov --cov-report=term-missing --forked
codecov
[testenv:py35]
@@ -55,7 +55,7 @@ deps =
pytest-xdist
wcwidth
commands =
- py.test {posargs: -n 2} --cov=cmd2 --cov-report=term-missing --forked
+ py.test {posargs: -n 2} --cov --cov-report=term-missing --forked
codecov
[testenv:py36-win]
@@ -68,7 +68,7 @@ deps =
pytest-cov
pytest-xdist
commands =
- py.test {posargs: -n 2} --cov=cmd2 --cov-report=term-missing
+ py.test {posargs: -n 2} --cov --cov-report=term-missing
codecov
[testenv:py37]