From f29d6af523243652fb5854579095f9a6ea5d400f Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Thu, 12 Apr 2018 13:50:02 -0400 Subject: [PATCH 01/26] Initial publishing of autocompleter. #349 --- AutoCompleter.py | 789 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 789 insertions(+) create mode 100755 AutoCompleter.py diff --git a/AutoCompleter.py b/AutoCompleter.py new file mode 100755 index 000000000..10274d932 --- /dev/null +++ b/AutoCompleter.py @@ -0,0 +1,789 @@ +# coding=utf-8 +import argparse +import re as _re +import sys +from argparse import OPTIONAL, ZERO_OR_MORE, ONE_OR_MORE, REMAINDER, PARSER +from typing import List, Dict, Tuple, Callable, Union + +from colorama import Fore + +from cmd2 import readline_lib + + +class _RangeAction(object): + def __init__(self, nargs): + 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, + arg_choices: Dict[str, Union[List, Tuple, Callable]] = None, + subcmd_args_lookup: dict = None, + tab_for_arg_help: bool = True): + """ + 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) + + + :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) + self._positional_completers = {} # maps action name to sub-command autocompleter: + # action_name -> dict(sub_command -> completer) + + # 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.keys() else {} + for subcmd in action.choices: + (subcmd_args, subcmd_lookup) = args_for_action[subcmd] if subcmd in args_for_action.keys() 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): + """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, ...]) + + def consume_flag_argument(): + """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 len(token) > 0 and not token[0] 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(): + """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.keys(): + flag_action = self._flag_to_action[token] + elif self._parser.allow_abbrev: + candidates_flags = [flag for flag in self._flag_to_action.keys() 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.keys(): + sub_completers = self._positional_completers[pos_name] + if token in sub_completers.keys(): + 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.keys() 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.keys() 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, arg_state): + 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, text: str, line: str, begidx: int, endidx: int, used_values=list()): + if action.dest in self._arg_choices.keys(): + 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): + 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, used_values=list()): + if action.dest in self._arg_choices.keys(): + 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): + 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') + + readline_lib.rl_forced_update_display() + + # noinspection PyUnusedLocal + @staticmethod + def basic_complete(text, line, begidx, endidx, match_against): + """ + 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)] + + +# Copied from argparse +from argparse import _ + + +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 = 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 = [] + required_options = [] + positionals = [] + for action in actions: + if action.option_strings: + if action.required: + required_options.append(action) + else: + optionals.append(action) + else: + positionals.append(action) + + # build full usage string + format = self._format_actions_usage + action_usage = format(positionals + 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: + + # 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 + + # 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) + 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] + + # if prog is long, put it on its own line + else: + indent = ' ' * len(prefix) + 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)) + 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) + + # if the Optional takes a value, format is: + # -s ARGS, --long ARGS + else: + default = self._get_default_metavar_for_optional(action) + args_string = self._format_args(action, default) + + # for option_string in action.option_strings: + # parts.append('%s %s' % (option_string, args_string)) + + return ', '.join(action.option_strings) + ' ' + args_string + + def _format_args(self, action, default_metavar): + get_metavar = self._metavar_formatter(action, default_metavar) + if action.nargs is None: + result = '%s' % get_metavar(1) + elif action.nargs == OPTIONAL: + result = '[%s]' % get_metavar(1) + elif action.nargs == ZERO_OR_MORE: + result = '[%s [...]]' % get_metavar(1) + elif action.nargs == ONE_OR_MORE: + result = '%s [...]' % get_metavar(1) + elif action.nargs == REMAINDER: + result = '...' + elif action.nargs == PARSER: + result = '%s ...' % get_metavar(1) + else: + formats = ['%s' for _ in range(action.nargs)] + result = ' '.join(formats) % get_metavar(action.nargs) + return result + + 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] + result = '{%s}' % ', '.join(choice_strs) + else: + result = default_metavar + + def format(tuple_size): + if isinstance(result, tuple): + return result + else: + return (result, ) * tuple_size + return format + + 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, + prog=None, + usage=None, + description=None, + epilog=None, + parents=[], + formatter_class=ACHelpFormatter, + prefix_chars='-', + fromfile_prefix_chars=None, + argument_default=None, + conflict_handler='error', + add_help=True, + allow_abbrev=True): + + super().__init__(prog=prog, + usage=usage, + description=description, + epilog=epilog, + parents=parents, + formatter_class=formatter_class, + prefix_chars=prefix_chars, + fromfile_prefix_chars=fromfile_prefix_chars, + argument_default=argument_default, + conflict_handler=conflict_handler, + add_help=add_help, + allow_abbrev=allow_abbrev) + register_custom_actions(self) + + self._custom_error_message = '' + + 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 + + def error(self, message): + """Custom error override.""" + 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) + 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) + + # 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() + + # 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) From e20f7ec3850db2658053f73f5c9023868d7f12c7 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Sat, 14 Apr 2018 14:15:16 -0700 Subject: [PATCH 02/26] Started working on an example for autocompleter usage. --- AutoCompleter.py | 4 +- examples/tab_autocompletion.py | 175 +++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 2 deletions(-) create mode 100755 examples/tab_autocompletion.py diff --git a/AutoCompleter.py b/AutoCompleter.py index 10274d932..753c8e63d 100755 --- a/AutoCompleter.py +++ b/AutoCompleter.py @@ -16,7 +16,7 @@ def __init__(self, nargs): self.nargs_max = None # pre-process special ranged nargs - if isinstance(nargs, Tuple): + 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]: @@ -122,7 +122,7 @@ def reset(self): def __init__(self, parser: argparse.ArgumentParser, - token_start_index: int, + 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): diff --git a/examples/tab_autocompletion.py b/examples/tab_autocompletion.py new file mode 100755 index 000000000..a28f839d1 --- /dev/null +++ b/examples/tab_autocompletion.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python +# coding=utf-8 +"""A simple example demonstrating how to use flag and index based tab-completion functions +""" +import argparse +import AutoCompleter + +import cmd2 +from cmd2 import with_argparser + +# List of strings used with flag and index based completion functions +food_item_strs = ['Pizza', 'Hamburger', 'Ham', 'Potato'] +sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football'] + + +class TabCompleteExample(cmd2.Cmd): + """ Example cmd2 application where we a base command which has a couple subcommands.""" + + def __init__(self): + cmd2.Cmd.__init__(self) + + # 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 = AutoCompleter.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_argparser(suggest_parser) + def do_suggest(self, args): + if not args.type: + self.do_help('suggest') + + def complete_suggest(self, text, line, begidx, endidx): + """ Adds tab completion to media""" + print('1') + completer = AutoCompleter.AutoCompleter(TabCompleteExample.suggest_parser, 1) + print('2') + tokens, _ = self.tokens_for_completion(line, begidx, endidx) + print('22') + results = completer.complete_command(tokens, text, line, begidx, endidx) + print('3') + + 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 + AutoCompleter.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_argparser(suggest_parser_hybrid) + def do_orig_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 = AutoCompleter.AutoCompleter(TabCompleteExample.suggest_parser_hybrid) + + tokens, _ = self.tokens_for_completion(line, begidx, endidx) + results = completer.complete_command(tokens, text, line, begidx, endidx) + + return results + + 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) + def do_orig_suggest(self, args): + if not args.type: + self.do_help('orig_suggest') + + def complete_orig_suggest(self, text, line, begidx, endidx): + """ Adds tab completion to media""" + completer = AutoCompleter.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 + # + + def query_actors(self): + """Simulating a function that queries and returns a completion values""" + return ['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'] + + def _do_media_movies(self, args): + if not args.command: + self.do_help('media movies') + + def _do_media_shows(self, args): + if not args.command: + self.do_help('media shows') + + # example choices list + ratings_types = ['G', 'PG', 'PG-13', 'R', 'NC-17'] + + media_parser = AutoCompleter.ACArgumentParser() + + 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('-t', '--title', help='Movie Title', required=True) + movies_add_parser.add_argument('-r', '--rating', help='Movie Rating', choices=ratings_types, required=True) + movies_add_parser.add_argument('-d', '--director', help='Director', action='append', required=True) + movies_add_parser.add_argument('-a', '--actor', help='Actors', action='append', required=True) + + 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) + + @with_argparser(media_parser) + def do_media(self, args): + """Media management""" + 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') + + def complete_media(self, text, line, begidx, endidx): + """ Adds tab completion to media""" + directors = ['J. J. Abrams', 'Irvin Kershner', 'George Lucas', 'Richard Marquand', + 'Rian Johnson', 'Gareth Edwards'] + choices = {'actor': self.query_actors, + 'director': directors} + completer = AutoCompleter.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 + + +if __name__ == '__main__': + app = TabCompleteExample() + app.cmdloop() From 8d4f8411bb5f6bbac8ec121f897ccece1b97006c Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Sun, 15 Apr 2018 17:36:49 -0400 Subject: [PATCH 03/26] * AutoCompleter - Fixed a few bugs in AutoCompleter dealing with nargs='+' or nargs='*' - Adjusted some help output dealing with narg ranges - Fixed spacing problem with printing argument help * examples/tab_autocompletion.py - Removed debug code. - Minor changes. --- AutoCompleter.py | 28 +++++++++++++++++++++++----- examples/tab_autocompletion.py | 27 +++++++++++++++++++-------- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/AutoCompleter.py b/AutoCompleter.py index 753c8e63d..014ea8ab8 100755 --- a/AutoCompleter.py +++ b/AutoCompleter.py @@ -2,7 +2,7 @@ import argparse import re as _re import sys -from argparse import OPTIONAL, ZERO_OR_MORE, ONE_OR_MORE, REMAINDER, PARSER +from argparse import OPTIONAL, ZERO_OR_MORE, ONE_OR_MORE, REMAINDER, PARSER, ArgumentError from typing import List, Dict, Tuple, Callable, Union from colorama import Fore @@ -460,14 +460,14 @@ def _print_action_help(self, action): if action.option_strings: flags = ', '.join(action.option_strings) param = '' - if action.nargs is None or action.nargs > 0: + 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) + prefix = ' {0: <{width}} '.format(prefix, width=20) pref_len = len(prefix) help_lines = action.help.splitlines() if len(help_lines) == 1: @@ -533,7 +533,7 @@ def _format_usage(self, usage, actions, groups, prefix): # build full usage string format = self._format_actions_usage - action_usage = format(positionals + optionals, groups) + 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 @@ -632,7 +632,11 @@ def _format_action_invocation(self, action): def _format_args(self, action, default_metavar): get_metavar = self._metavar_formatter(action, default_metavar) - if action.nargs is None: + 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 is None: result = '%s' % get_metavar(1) elif action.nargs == OPTIONAL: result = '[%s]' % get_metavar(1) @@ -787,3 +791,17 @@ def _get_nargs_pattern(self, action): 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/examples/tab_autocompletion.py b/examples/tab_autocompletion.py index a28f839d1..7b978fede 100755 --- a/examples/tab_autocompletion.py +++ b/examples/tab_autocompletion.py @@ -34,18 +34,21 @@ def __init__(self): @with_argparser(suggest_parser) def do_suggest(self, args): + """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, line, begidx, endidx): """ Adds tab completion to media""" - print('1') completer = AutoCompleter.AutoCompleter(TabCompleteExample.suggest_parser, 1) - print('2') + tokens, _ = self.tokens_for_completion(line, begidx, endidx) - print('22') results = completer.complete_command(tokens, text, line, begidx, endidx) - print('3') return results @@ -62,7 +65,7 @@ def complete_suggest(self, text, line, begidx, endidx): '\tsingle value - maximum duration\n' '\t[a, b] - duration range') @with_argparser(suggest_parser_hybrid) - def do_orig_suggest(self, args): + def do_hybrid_suggest(self, args): if not args.type: self.do_help('orig_suggest') @@ -75,6 +78,10 @@ def complete_hybrid_suggest(self, 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) @@ -147,7 +154,7 @@ def _do_media_shows(self, args): @with_argparser(media_parser) def do_media(self, args): - """Media management""" + """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 @@ -156,12 +163,16 @@ def do_media(self, args): # 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""" - directors = ['J. J. Abrams', 'Irvin Kershner', 'George Lucas', 'Richard Marquand', + static_list_directors = ['J. J. Abrams', 'Irvin Kershner', 'George Lucas', 'Richard Marquand', 'Rian Johnson', 'Gareth Edwards'] choices = {'actor': self.query_actors, - 'director': directors} + 'director': static_list_directors} completer = AutoCompleter.AutoCompleter(TabCompleteExample.media_parser, arg_choices=choices) tokens, _ = self.tokens_for_completion(line, begidx, endidx) From c99b9a24b24e7d9b9f5b551504ade7cdba4bc18b Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Sun, 15 Apr 2018 23:32:25 -0400 Subject: [PATCH 04/26] Matched changes in the python3 branch. --- examples/tab_autocompletion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/tab_autocompletion.py b/examples/tab_autocompletion.py index 7b978fede..fab4ce016 100755 --- a/examples/tab_autocompletion.py +++ b/examples/tab_autocompletion.py @@ -17,7 +17,7 @@ class TabCompleteExample(cmd2.Cmd): """ Example cmd2 application where we a base command which has a couple subcommands.""" def __init__(self): - cmd2.Cmd.__init__(self) + super().__init__() # This demonstrates a number of customizations of the AutoCompleter version of ArgumentParser # - The help output will separately group required vs optional flags From 21454b296867b0fce9f1afb70ca0476fe4b9ae2f Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Mon, 16 Apr 2018 08:29:37 -0700 Subject: [PATCH 05/26] Changed setup.py requirement for pyperclip to >= 1.5.27 instead of 1.6.0 This is to support installation from package managers on older OSes such as Debian 9. --- cmd2.py | 8 +++++++- setup.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/cmd2.py b/cmd2.py index 6ac1fee26..54eff811b 100755 --- a/cmd2.py +++ b/cmd2.py @@ -52,8 +52,14 @@ import pyparsing import pyperclip -from pyperclip import PyperclipException +# 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: diff --git a/setup.py b/setup.py index c231e7ea9..88e4cf7de 100755 --- a/setup.py +++ b/setup.py @@ -61,7 +61,7 @@ Topic :: Software Development :: Libraries :: Python Modules """.splitlines()))) -INSTALL_REQUIRES = ['pyparsing >= 2.1.0', 'pyperclip >= 1.6.0'] +INSTALL_REQUIRES = ['pyparsing >= 2.1.0', 'pyperclip >= 1.5.27'] EXTRAS_REQUIRE = { # Windows also requires pyreadline to ensure tab completion works From bb5e35803a491e85e91178e71fba9b362b08b3e7 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Mon, 16 Apr 2018 15:11:13 -0400 Subject: [PATCH 06/26] Added more advanced/complex autocompleter examples. Added more type hinting to AutoCompleter. --- AutoCompleter.py | 1620 ++++++++++++++++---------------- examples/tab_autocompletion.py | 194 +++- 2 files changed, 987 insertions(+), 827 deletions(-) diff --git a/AutoCompleter.py b/AutoCompleter.py index 014ea8ab8..e2af5ed98 100755 --- a/AutoCompleter.py +++ b/AutoCompleter.py @@ -1,807 +1,813 @@ -# coding=utf-8 -import argparse -import re as _re -import sys -from argparse import OPTIONAL, ZERO_OR_MORE, ONE_OR_MORE, REMAINDER, PARSER, ArgumentError -from typing import List, Dict, Tuple, Callable, Union - -from colorama import Fore - -from cmd2 import readline_lib - - -class _RangeAction(object): - def __init__(self, nargs): - 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): - """ - 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) - - - :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) - self._positional_completers = {} # maps action name to sub-command autocompleter: - # action_name -> dict(sub_command -> completer) - - # 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.keys() else {} - for subcmd in action.choices: - (subcmd_args, subcmd_lookup) = args_for_action[subcmd] if subcmd in args_for_action.keys() 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): - """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, ...]) - - def consume_flag_argument(): - """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 len(token) > 0 and not token[0] 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(): - """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.keys(): - flag_action = self._flag_to_action[token] - elif self._parser.allow_abbrev: - candidates_flags = [flag for flag in self._flag_to_action.keys() 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.keys(): - sub_completers = self._positional_completers[pos_name] - if token in sub_completers.keys(): - 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.keys() 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.keys() 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, arg_state): - 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, text: str, line: str, begidx: int, endidx: int, used_values=list()): - if action.dest in self._arg_choices.keys(): - 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): - 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, used_values=list()): - if action.dest in self._arg_choices.keys(): - 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): - 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') - - readline_lib.rl_forced_update_display() - - # noinspection PyUnusedLocal - @staticmethod - def basic_complete(text, line, begidx, endidx, match_against): - """ - 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)] - - -# Copied from argparse -from argparse import _ - - -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 = 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 = [] - required_options = [] - positionals = [] - for action in actions: - if action.option_strings: - if action.required: - required_options.append(action) - else: - optionals.append(action) - else: - positionals.append(action) - - # 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: - - # 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 - - # 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) - 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] - - # if prog is long, put it on its own line - else: - indent = ' ' * len(prefix) - 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)) - 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) - - # if the Optional takes a value, format is: - # -s ARGS, --long ARGS - else: - default = self._get_default_metavar_for_optional(action) - args_string = self._format_args(action, default) - - # for option_string in action.option_strings: - # parts.append('%s %s' % (option_string, args_string)) - - return ', '.join(action.option_strings) + ' ' + args_string - - def _format_args(self, action, default_metavar): - get_metavar = self._metavar_formatter(action, default_metavar) - 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 is None: - result = '%s' % get_metavar(1) - elif action.nargs == OPTIONAL: - result = '[%s]' % get_metavar(1) - elif action.nargs == ZERO_OR_MORE: - result = '[%s [...]]' % get_metavar(1) - elif action.nargs == ONE_OR_MORE: - result = '%s [...]' % get_metavar(1) - elif action.nargs == REMAINDER: - result = '...' - elif action.nargs == PARSER: - result = '%s ...' % get_metavar(1) - else: - formats = ['%s' for _ in range(action.nargs)] - result = ' '.join(formats) % get_metavar(action.nargs) - return result - - 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] - result = '{%s}' % ', '.join(choice_strs) - else: - result = default_metavar - - def format(tuple_size): - if isinstance(result, tuple): - return result - else: - return (result, ) * tuple_size - return format - - 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, - prog=None, - usage=None, - description=None, - epilog=None, - parents=[], - formatter_class=ACHelpFormatter, - prefix_chars='-', - fromfile_prefix_chars=None, - argument_default=None, - conflict_handler='error', - add_help=True, - allow_abbrev=True): - - super().__init__(prog=prog, - usage=usage, - description=description, - epilog=epilog, - parents=parents, - formatter_class=formatter_class, - prefix_chars=prefix_chars, - fromfile_prefix_chars=fromfile_prefix_chars, - argument_default=argument_default, - conflict_handler=conflict_handler, - add_help=add_help, - allow_abbrev=allow_abbrev) - register_custom_actions(self) - - self._custom_error_message = '' - - 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 - - def error(self, message): - """Custom error override.""" - 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) - 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) - - # 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() - - # 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) +# coding=utf-8 +import argparse +import re as _re +import sys +from argparse import OPTIONAL, ZERO_OR_MORE, ONE_OR_MORE, REMAINDER, PARSER, ArgumentError, _ +from typing import List, Dict, Tuple, Callable, Union + +from colorama import Fore + + +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): + """ + 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) + + + :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) + self._positional_completers = {} # maps action name to sub-command autocompleter: + # action_name -> dict(sub_command -> completer) + + # 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.keys() else {} + for subcmd in action.choices: + (subcmd_args, subcmd_lookup) = args_for_action[subcmd] if \ + subcmd in args_for_action.keys() 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, ...]) + + 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 len(token) > 0 and not token[0] 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.keys(): + flag_action = self._flag_to_action[token] + elif self._parser.allow_abbrev: + candidates_flags = [flag for flag in self._flag_to_action.keys() 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.keys(): + sub_completers = self._positional_completers[pos_name] + if token in sub_completers.keys(): + 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.keys() 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.keys() 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()) -> List[str]: + if action.dest in self._arg_choices.keys(): + 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): + 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()) -> List[str]: + if action.dest in self._arg_choices.keys(): + 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') + + from cmd2 import readline_lib + readline_lib.rl_forced_update_display() + + # 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)] + + +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 = [] + required_options = [] + positionals = [] + for action in actions: + if action.option_strings: + if action.required: + required_options.append(action) + else: + optionals.append(action) + else: + positionals.append(action) + + # 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: + + # 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 + + # 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) + 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] + + # if prog is long, put it on its own line + else: + indent = ' ' * len(prefix) + 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)) + 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) + + # if the Optional takes a value, format is: + # -s ARGS, --long ARGS + else: + default = self._get_default_metavar_for_optional(action) + args_string = self._format_args(action, default) + + # for option_string in action.option_strings: + # parts.append('%s %s' % (option_string, args_string)) + + return ', '.join(action.option_strings) + ' ' + args_string + + def _format_args(self, action, default_metavar): + get_metavar = self._metavar_formatter(action, default_metavar) + 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 is None: + result = '%s' % get_metavar(1) + elif action.nargs == OPTIONAL: + result = '[%s]' % get_metavar(1) + elif action.nargs == ZERO_OR_MORE: + result = '[%s [...]]' % get_metavar(1) + elif action.nargs == ONE_OR_MORE: + result = '%s [...]' % get_metavar(1) + elif action.nargs == REMAINDER: + result = '...' + elif action.nargs == PARSER: + result = '%s ...' % get_metavar(1) + else: + formats = ['%s' for _ in range(action.nargs)] + result = ' '.join(formats) % get_metavar(action.nargs) + return result + + 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] + result = '{%s}' % ', '.join(choice_strs) + else: + result = default_metavar + + def format(tuple_size): + if isinstance(result, tuple): + return result + else: + return (result, ) * tuple_size + return format + + 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, + prog=None, + usage=None, + description=None, + epilog=None, + parents=None, + formatter_class=ACHelpFormatter, + prefix_chars='-', + fromfile_prefix_chars=None, + argument_default=None, + conflict_handler='error', + add_help=True, + allow_abbrev=True): + + super().__init__(prog=prog, + usage=usage, + description=description, + epilog=epilog, + parents=parents, + formatter_class=formatter_class, + prefix_chars=prefix_chars, + fromfile_prefix_chars=fromfile_prefix_chars, + argument_default=argument_default, + conflict_handler=conflict_handler, + add_help=add_help, + allow_abbrev=allow_abbrev) + register_custom_actions(self) + + self._custom_error_message = '' + + 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 + + def error(self, message): + """Custom error override.""" + 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) + 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) + + # 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() + + # 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/examples/tab_autocompletion.py b/examples/tab_autocompletion.py index fab4ce016..8cbe7f0a0 100755 --- a/examples/tab_autocompletion.py +++ b/examples/tab_autocompletion.py @@ -4,9 +4,10 @@ """ import argparse import AutoCompleter +from typing import List import cmd2 -from cmd2 import with_argparser +from cmd2 import with_argparser, with_category # List of strings used with flag and index based completion functions food_item_strs = ['Pizza', 'Hamburger', 'Ham', 'Potato'] @@ -16,9 +17,60 @@ 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'] + 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'] + }, + + } + # 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 @@ -32,8 +84,9 @@ def __init__(self): '\tsingle value - maximum duration\n' '\t[a, b] - duration range') + @with_category(CAT_AUTOCOMPLETE) @with_argparser(suggest_parser) - def do_suggest(self, args): + def do_suggest(self, args) -> None: """Suggest command demonstrates argparse customizations See hybrid_suggest and orig_suggest to compare the help output. @@ -43,7 +96,7 @@ def do_suggest(self, args): if not args.type: self.do_help('suggest') - def complete_suggest(self, text, line, begidx, endidx): + def complete_suggest(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: """ Adds tab completion to media""" completer = AutoCompleter.AutoCompleter(TabCompleteExample.suggest_parser, 1) @@ -64,6 +117,8 @@ def complete_suggest(self, text, line, begidx, endidx): 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: @@ -89,12 +144,14 @@ def complete_hybrid_suggest(self, text, line, begidx, endidx): help='Duration constraint in minutes.\n' '\tsingle value - maximum duration\n' '\t[a, b] - duration range') + @with_argparser(suggest_parser_orig) - def do_orig_suggest(self, args): + @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): + def complete_orig_suggest(self, text, line, begidx, endidx) -> List[str]: """ Adds tab completion to media""" completer = AutoCompleter.AutoCompleter(TabCompleteExample.suggest_parser_orig) @@ -103,27 +160,29 @@ def complete_orig_suggest(self, 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): + def query_actors(self) -> List[str]: """Simulating a function that queries and returns a completion values""" - return ['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'] + return TabCompleteExample.actors - def _do_media_movies(self, args): + def _do_media_movies(self, args) -> None: if not args.command: self.do_help('media movies') - - def _do_media_shows(self, args): + 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') - # example choices list - ratings_types = ['G', 'PG', 'PG-13', 'R', 'NC-17'] - media_parser = AutoCompleter.ACArgumentParser() media_types_subparsers = media_parser.add_subparsers(title='Media Types', dest='type') @@ -152,6 +211,7 @@ def _do_media_shows(self, args): shows_parser = media_types_subparsers.add_parser('shows') shows_parser.set_defaults(func=_do_media_shows) + @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""" @@ -169,10 +229,9 @@ def do_media(self, args): # name collisions. def complete_media(self, text, line, begidx, endidx): """ Adds tab completion to media""" - static_list_directors = ['J. J. Abrams', 'Irvin Kershner', 'George Lucas', 'Richard Marquand', - 'Rian Johnson', 'Gareth Edwards'] - choices = {'actor': self.query_actors, - 'director': static_list_directors} + choices = {'actor': self.query_actors, # function + 'director': TabCompleteExample.static_list_directors # static list + } completer = AutoCompleter.AutoCompleter(TabCompleteExample.media_parser, arg_choices=choices) tokens, _ = self.tokens_for_completion(line, begidx, endidx) @@ -180,6 +239,101 @@ def complete_media(self, 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, exclude=[]): + return list(set(TabCompleteExample.MOVIE_DATABASE_IDS).difference(set(exclude))) + + def _query_movie_user_library(self): + return TabCompleteExample.USER_MOVIE_LIBRARY + + library_parser = AutoCompleter.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_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) + + @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} + + # 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)} + + # The 'library movie' command has a sub-parser group called 'command' + library_movie_subcommand_groups = {'command': library_movie_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, None)} + + # 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 = AutoCompleter.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() From 81adb0457c58b3d1095b09b14fdbc647c971482a Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Mon, 16 Apr 2018 21:53:45 -0400 Subject: [PATCH 07/26] Added unit tests for AutoCompleter. --- AutoCompleter.py | 2 +- examples/tab_autocompletion.py | 19 +-- tests/test_autocompletion.py | 301 +++++++++++++++++++++++++++++++++ 3 files changed, 310 insertions(+), 12 deletions(-) create mode 100644 tests/test_autocompletion.py diff --git a/AutoCompleter.py b/AutoCompleter.py index e2af5ed98..834c88fff 100755 --- a/AutoCompleter.py +++ b/AutoCompleter.py @@ -687,7 +687,7 @@ def __init__(self, usage=None, description=None, epilog=None, - parents=None, + parents=[], formatter_class=ACHelpFormatter, prefix_chars='-', fromfile_prefix_chars=None, diff --git a/examples/tab_autocompletion.py b/examples/tab_autocompletion.py index 8cbe7f0a0..69105b673 100755 --- a/examples/tab_autocompletion.py +++ b/examples/tab_autocompletion.py @@ -9,10 +9,6 @@ import cmd2 from cmd2 import with_argparser, with_category -# List of strings used with flag and index based completion functions -food_item_strs = ['Pizza', 'Hamburger', 'Ham', 'Potato'] -sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football'] - class TabCompleteExample(cmd2.Cmd): """ Example cmd2 application where we a base command which has a couple subcommands.""" @@ -183,7 +179,7 @@ def _do_media_shows(self, args) -> None: if not args.command: self.do_help('media shows') - media_parser = AutoCompleter.ACArgumentParser() + media_parser = AutoCompleter.ACArgumentParser(prog='media') media_types_subparsers = media_parser.add_subparsers(title='Media Types', dest='type') @@ -201,10 +197,10 @@ def _do_media_shows(self, args) -> None: 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('-t', '--title', help='Movie Title', required=True) - movies_add_parser.add_argument('-r', '--rating', help='Movie Rating', choices=ratings_types, required=True) - movies_add_parser.add_argument('-d', '--director', help='Director', action='append', required=True) - movies_add_parser.add_argument('-a', '--actor', help='Actors', action='append', required=True) + 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') @@ -255,8 +251,8 @@ def _do_library_show(self, args): if not args.type: self.do_help('library show') - def _query_movie_database(self, exclude=[]): - return list(set(TabCompleteExample.MOVIE_DATABASE_IDS).difference(set(exclude))) + 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 @@ -272,6 +268,7 @@ def _query_movie_user_library(self): 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') diff --git a/tests/test_autocompletion.py b/tests/test_autocompletion.py new file mode 100644 index 000000000..f65f73984 --- /dev/null +++ b/tests/test_autocompletion.py @@ -0,0 +1,301 @@ +# coding=utf-8 +""" +Unit/functional testing for readline tab-completion functions in the cmd2.py module. + +These are primarily tests related to readline completer functions which handle tab-completion of cmd2/cmd commands, +file system paths, and shell commands. + +Copyright 2017 Todd Leonhardt +Released under MIT license, see LICENSE file +""" +import argparse +import os +import sys + +import cmd2 +from unittest import mock +import pytest +from conftest import run_cmd, normalize, StdOut + +MY_PATH = os.path.realpath(__file__) +sys.path.append(os.path.join(MY_PATH, '..', 'examples')) + +from examples.tab_autocompletion import TabCompleteExample + +# 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 + + +@pytest.fixture +def cmd2_app(): + c = TabCompleteExample() + 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 + + +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" '] + + + + + + From 50c96bcf29c5bdc7f25680526349e0b08b20dbf0 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Mon, 16 Apr 2018 22:17:17 -0400 Subject: [PATCH 08/26] Tweaks for Python 3.4 --- AutoCompleter.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/AutoCompleter.py b/AutoCompleter.py index 834c88fff..e995284ed 100755 --- a/AutoCompleter.py +++ b/AutoCompleter.py @@ -3,9 +3,11 @@ import re as _re import sys from argparse import OPTIONAL, ZERO_OR_MORE, ONE_OR_MORE, REMAINDER, PARSER, ArgumentError, _ -from typing import List, Dict, Tuple, Callable, Union - -from colorama import Fore +try: + from typing import List, Dict, Tuple, Callable, Union +except: + pass +# from colorama import Fore class _RangeAction(object): @@ -735,7 +737,8 @@ def error(self, message): formatted_message += '\n ' + line linum += 1 - sys.stderr.write(Fore.LIGHTRED_EX + '{}\n\n'.format(formatted_message) + Fore.RESET) + # 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) From cd60899b13057f769686e9bf0df555955436592c Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Mon, 16 Apr 2018 22:27:02 -0400 Subject: [PATCH 09/26] Try adding typing for Python 3.4 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 88e4cf7de..8d3f9a8ac 100755 --- a/setup.py +++ b/setup.py @@ -69,7 +69,7 @@ # 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: From d5d42fbc1ec809723eb353a5d1b1c4ead36e2bb1 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Mon, 16 Apr 2018 22:41:14 -0400 Subject: [PATCH 10/26] More changes for Python 3.4 --- AutoCompleter.py | 28 ++++++++++++++++------------ setup.py | 1 + 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/AutoCompleter.py b/AutoCompleter.py index e995284ed..7ce29b0fe 100755 --- a/AutoCompleter.py +++ b/AutoCompleter.py @@ -698,18 +698,22 @@ def __init__(self, add_help=True, allow_abbrev=True): - super().__init__(prog=prog, - usage=usage, - description=description, - epilog=epilog, - parents=parents, - formatter_class=formatter_class, - prefix_chars=prefix_chars, - fromfile_prefix_chars=fromfile_prefix_chars, - argument_default=argument_default, - conflict_handler=conflict_handler, - add_help=add_help, - allow_abbrev=allow_abbrev) + params = {'prog': prog, + 'usage': usage, + 'description': description, + 'epilog': epilog, + 'parents': parents, + 'formatter_class': formatter_class, + 'prefix_chars': prefix_chars, + 'fromfile_prefix_chars': fromfile_prefix_chars, + 'argument_default': argument_default, + 'conflict_handler': conflict_handler, + 'add_help': add_help} + + if sys.version_info >= (3, 5): + params['allow_abbrev'] = allow_abbrev + + super().__init__(**params) register_custom_actions(self) self._custom_error_message = '' diff --git a/setup.py b/setup.py index 8d3f9a8ac..0944c5858 100755 --- a/setup.py +++ b/setup.py @@ -80,6 +80,7 @@ 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'] From 07c283eb484c9f2513f23594b364ad331b2b0e81 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Mon, 16 Apr 2018 22:44:48 -0400 Subject: [PATCH 11/26] Added check for allow_abbrev attribute before accessing it (for Python 3.4 compatibility) --- AutoCompleter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AutoCompleter.py b/AutoCompleter.py index 7ce29b0fe..83228b3bf 100755 --- a/AutoCompleter.py +++ b/AutoCompleter.py @@ -299,7 +299,7 @@ def consume_positional_argument() -> None: # does the token fully match a known flag? if token in self._flag_to_action.keys(): flag_action = self._flag_to_action[token] - elif self._parser.allow_abbrev: + elif hasattr(self._parser, 'allow_abbrev') and self._parser.allow_abbrev: candidates_flags = [flag for flag in self._flag_to_action.keys() if flag.startswith(token)] if len(candidates_flags) == 1: flag_action = self._flag_to_action[candidates_flags[0]] From dac2680f8433b94fb24fcfad635a096fd23386e9 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 17 Apr 2018 00:02:14 -0400 Subject: [PATCH 12/26] Created a common prompt reprint implementation for all supported platforms. --- AutoCompleter.py | 6 ++++-- cmd2.py | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/AutoCompleter.py b/AutoCompleter.py index 83228b3bf..671195c95 100755 --- a/AutoCompleter.py +++ b/AutoCompleter.py @@ -488,8 +488,10 @@ def _print_action_help(self, action: argparse.Action) -> None: out_str += '\n{0: <{width}}'.format('', width=pref_len).join(help_lines) print('\nHint:' + out_str + '\n') - from cmd2 import readline_lib - readline_lib.rl_forced_update_display() + # Moving this import here improves the performance when using AutoCompleter in a + # bash completion function. Loading cmd2 results in a lag of about half a second to a second. + from cmd2 import reprint_prompt + reprint_prompt() # noinspection PyUnusedLocal @staticmethod diff --git a/cmd2.py b/cmd2.py index 54eff811b..6a2449a52 100755 --- a/cmd2.py +++ b/cmd2.py @@ -392,6 +392,13 @@ def write_to_paste_buffer(txt): pyperclip.copy(txt) +def reprint_prompt(): + if rl_type == RlType.GNU: + readline_lib.rl_forced_update_display() + elif rl_type == RlType.PYREADLINE: + readline.rl.mode._print_prompt() + + class ParsedString(str): """Subclass of str which also stores a pyparsing.ParseResults object containing structured parse results.""" # pyarsing.ParseResults - structured parse results, to provide multiple means of access to the parsed data From dadcd2712c3f5830128a8c70e2a449f9f2b59b05 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 17 Apr 2018 01:20:30 -0400 Subject: [PATCH 13/26] Marked the 2 tests failing the windows unit test as skip for windows. It works fine when I test by hand. --- tests/test_autocompletion.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_autocompletion.py b/tests/test_autocompletion.py index f65f73984..aa82adad0 100644 --- a/tests/test_autocompletion.py +++ b/tests/test_autocompletion.py @@ -1,4 +1,3 @@ -# coding=utf-8 """ Unit/functional testing for readline tab-completion functions in the cmd2.py module. @@ -68,14 +67,14 @@ def get_begidx(): def get_endidx(): return endidx - first_match = None + first_match = [] 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 + return first_match if not None else [] SUGGEST_HELP = '''Usage: suggest -t {movie, show} [-h] [-d DURATION{1..2}] @@ -146,6 +145,8 @@ def test_autocomp_flags(cmd2_app): assert first_match is not None and \ cmd2_app.completion_matches == ['--duration', '--help', '--type', '-d', '-h', '-t'] +@pytest.mark.skipif(sys.platform == 'win32', + reason="Unit test doesn't work on win32, but feature does") def test_autcomp_hint(cmd2_app, capsys): text = '' line = 'suggest -d {}'.format(text) @@ -187,6 +188,8 @@ def test_autocomp_flags_choices(cmd2_app): cmd2_app.completion_matches == ['movie', 'show'] +@pytest.mark.skipif(sys.platform == 'win32', + reason="Unit test doesn't work on win32, but feature does") def test_autcomp_hint_in_narg_range(cmd2_app, capsys): text = '' line = 'suggest -d 2 {}'.format(text) From 9e24c983806c3cb6ff0722f5f2314ff56de2933c Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 17 Apr 2018 01:23:07 -0400 Subject: [PATCH 14/26] Added common file to provide readline utility functions --- AutoCompleter.py | 5 +-- cmd2.py | 81 ++++++++++++++++-------------------------------- rl_utils.py | 61 ++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 57 deletions(-) create mode 100644 rl_utils.py diff --git a/AutoCompleter.py b/AutoCompleter.py index 83228b3bf..f188a46e9 100755 --- a/AutoCompleter.py +++ b/AutoCompleter.py @@ -3,6 +3,7 @@ import re as _re import sys from argparse import OPTIONAL, ZERO_OR_MORE, ONE_OR_MORE, REMAINDER, PARSER, ArgumentError, _ +from rl_utils import rl_force_redisplay try: from typing import List, Dict, Tuple, Callable, Union except: @@ -488,8 +489,8 @@ def _print_action_help(self, action: argparse.Action) -> None: out_str += '\n{0: <{width}}'.format('', width=pref_len).join(help_lines) print('\nHint:' + out_str + '\n') - from cmd2 import readline_lib - readline_lib.rl_forced_update_display() + # Redraw prompt and input line + rl_force_redisplay() # noinspection PyUnusedLocal @staticmethod diff --git a/cmd2.py b/cmd2.py index 54eff811b..bdf8c95cf 100755 --- a/cmd2.py +++ b/cmd2.py @@ -45,21 +45,38 @@ 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 @@ def __subclasshook__(cls, C): 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 @@ -1669,13 +1645,8 @@ def _display_matches_gnu_readline(self, substitution, matches, longest_match_len # 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): """ @@ -1695,7 +1666,7 @@ def _display_matches_pyreadline(self, matches): # 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 ----- diff --git a/rl_utils.py b/rl_utils.py new file mode 100644 index 000000000..11c45ee44 --- /dev/null +++ b/rl_utils.py @@ -0,0 +1,61 @@ +# 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: + pass + + +# Check what implementation of readline we are using +class RlType(Enum): + GNU = 1 + PYREADLINE = 2 + NONE = 3 + + +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 rl_type == RlType.GNU: + # 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: + # noinspection PyProtectedMember + readline.rl.mode._print_prompt() From de471a898eecdc49addf5912d6b43aa218ee85da Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 17 Apr 2018 12:04:08 -0400 Subject: [PATCH 15/26] Some minor tweaks to AutoCompleter handling a collection of index-based function arguments. Added example for fully custom completion functions mixed with argparse/AutoCompleter handling - Also demonstrates the ability to pass in a list, tuple, or dict of parameters to append to the custom completion function. Added new test cases exercising the custom completion function calls. Added AutoCompleter and rl_utils to the coverage report. --- AutoCompleter.py | 2 +- examples/tab_autocompletion.py | 90 +++++++++++++++++++++++++++++++++- tests/test_acargparse.py | 76 ++++++++++++++++++++++++++++ tests/test_autocompletion.py | 22 +++++++++ tox.ini | 6 +-- 5 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 tests/test_acargparse.py diff --git a/AutoCompleter.py b/AutoCompleter.py index f188a46e9..dea47e674 100755 --- a/AutoCompleter.py +++ b/AutoCompleter.py @@ -429,7 +429,7 @@ def _complete_for_arg(self, action: argparse.Action, list_args = None kw_args = None for index in range(1, len(arg_choices)): - if isinstance(arg_choices[index], list): + 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] diff --git a/examples/tab_autocompletion.py b/examples/tab_autocompletion.py index 69105b673..f1453c598 100755 --- a/examples/tab_autocompletion.py +++ b/examples/tab_autocompletion.py @@ -4,6 +4,7 @@ """ import argparse import AutoCompleter +import itertools from typing import List import cmd2 @@ -20,6 +21,7 @@ def __init__(self): # 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', @@ -66,6 +68,24 @@ def __init__(self): }, } + 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 @@ -179,6 +199,19 @@ 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 = AutoCompleter.ACArgumentParser(prog='media') media_types_subparsers = media_parser.add_subparsers(title='Media Types', dest='type') @@ -207,6 +240,10 @@ def _do_media_shows(self, args) -> None: 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): @@ -257,6 +294,10 @@ def _query_movie_database(self): 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 = AutoCompleter.ACArgumentParser(prog='library') library_subcommands = library_parser.add_subparsers(title='Media Types', dest='type') @@ -276,6 +317,32 @@ def _query_movie_user_library(self): 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): @@ -300,21 +367,42 @@ def complete_library(self, text, line, begidx, endidx): 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, None)} + '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) diff --git a/tests/test_acargparse.py b/tests/test_acargparse.py new file mode 100644 index 000000000..01b4dce92 --- /dev/null +++ b/tests/test_acargparse.py @@ -0,0 +1,76 @@ +""" +Unit/functional testing for readline tab-completion functions in the cmd2.py module. + +These are primarily tests related to readline completer functions which handle tab-completion of cmd2/cmd commands, +file system paths, and shell commands. + +Copyright 2017 Todd Leonhardt +Released under MIT license, see LICENSE file +""" +import argparse +import os +import sys + +import cmd2 +from unittest import mock +import pytest +from AutoCompleter import ACArgumentParser + +# 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 + + +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 index aa82adad0..7f61f997e 100644 --- a/tests/test_autocompletion.py +++ b/tests/test_autocompletion.py @@ -298,6 +298,28 @@ def test_autcomp_pos_after_flag(cmd2_app): 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/tox.ini b/tox.ini index f68c1cb8f..88fd47d96 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=cmd2 --cov=AutoCompleter --cov=rl_utils --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=cmd2 --cov=AutoCompleter --cov=rl_utils --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=cmd2 --cov=AutoCompleter --cov=rl_utils --cov-report=term-missing codecov [testenv:py37] From 658562235b9f4fd7b5ebb01b386cba9dc541e4e7 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 17 Apr 2018 12:21:52 -0400 Subject: [PATCH 16/26] Bringing back color. Updated dependencies to include colorama. --- AutoCompleter.py | 6 +++--- setup.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/AutoCompleter.py b/AutoCompleter.py index dea47e674..5e6d04bfe 100755 --- a/AutoCompleter.py +++ b/AutoCompleter.py @@ -8,7 +8,7 @@ from typing import List, Dict, Tuple, Callable, Union except: pass -# from colorama import Fore +from colorama import Fore class _RangeAction(object): @@ -742,8 +742,8 @@ def error(self, message): 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)) + 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) diff --git a/setup.py b/setup.py index 0944c5858..e90f49d64 100755 --- a/setup.py +++ b/setup.py @@ -61,7 +61,7 @@ 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 From 93cc2461282067a3601d6ac546a99a40a60eef93 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 17 Apr 2018 23:50:57 -0400 Subject: [PATCH 17/26] Added check for whether the terminal is present before reprinting the prompt. Re-enabled test cases that were failing due to there not being a terminal during unit tests. --- rl_utils.py | 2 ++ tests/test_autocompletion.py | 4 ---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/rl_utils.py b/rl_utils.py index 11c45ee44..1dc83d152 100644 --- a/rl_utils.py +++ b/rl_utils.py @@ -47,6 +47,8 @@ def rl_force_redisplay() -> None: """ Causes readline to redraw prompt and input line """ + if not sys.stdout.isatty(): + return if rl_type == RlType.GNU: # 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 diff --git a/tests/test_autocompletion.py b/tests/test_autocompletion.py index 7f61f997e..bca467944 100644 --- a/tests/test_autocompletion.py +++ b/tests/test_autocompletion.py @@ -145,8 +145,6 @@ def test_autocomp_flags(cmd2_app): assert first_match is not None and \ cmd2_app.completion_matches == ['--duration', '--help', '--type', '-d', '-h', '-t'] -@pytest.mark.skipif(sys.platform == 'win32', - reason="Unit test doesn't work on win32, but feature does") def test_autcomp_hint(cmd2_app, capsys): text = '' line = 'suggest -d {}'.format(text) @@ -188,8 +186,6 @@ def test_autocomp_flags_choices(cmd2_app): cmd2_app.completion_matches == ['movie', 'show'] -@pytest.mark.skipif(sys.platform == 'win32', - reason="Unit test doesn't work on win32, but feature does") def test_autcomp_hint_in_narg_range(cmd2_app, capsys): text = '' line = 'suggest -d 2 {}'.format(text) From 8a5e2ffa464693bd915937721d2b7c17152110e9 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Wed, 18 Apr 2018 00:09:35 -0400 Subject: [PATCH 18/26] Should fix linux import --- cmd2/cmd2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 34f3dfc58..afe9bc84d 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -64,7 +64,7 @@ # Get the readline lib so we can make changes to it import ctypes - from rl_utils import readline_lib + 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") From 736cdda5f80682fadfc1556dae92046d2e9c770c Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Wed, 18 Apr 2018 00:18:39 -0400 Subject: [PATCH 19/26] Adding back Pyperclip imports that got mixed up in merge --- cmd2/cmd2.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index afe9bc84d..87db423a1 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -70,6 +70,13 @@ 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: From 9452dfa7748a1d3477e92a42a35747196dc9e052 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Wed, 18 Apr 2018 10:27:02 -0400 Subject: [PATCH 20/26] Tweaked AutoCompleter.ACArgumentParser's constructor to pass through constructor parameters in a more concise/general way. May also resolve the weird Mac issue on Python 3.6 --- cmd2/AutoCompleter.py | 35 +++++------------------------------ docs/requirements.txt | 3 +-- 2 files changed, 6 insertions(+), 32 deletions(-) diff --git a/cmd2/AutoCompleter.py b/cmd2/AutoCompleter.py index 7f79cec16..a8d258956 100755 --- a/cmd2/AutoCompleter.py +++ b/cmd2/AutoCompleter.py @@ -685,36 +685,11 @@ def _split_lines(self, text, width): class ACArgumentParser(argparse.ArgumentParser): """Custom argparse class to override error method to change default help text.""" - def __init__(self, - prog=None, - usage=None, - description=None, - epilog=None, - parents=[], - formatter_class=ACHelpFormatter, - prefix_chars='-', - fromfile_prefix_chars=None, - argument_default=None, - conflict_handler='error', - add_help=True, - allow_abbrev=True): - - params = {'prog': prog, - 'usage': usage, - 'description': description, - 'epilog': epilog, - 'parents': parents, - 'formatter_class': formatter_class, - 'prefix_chars': prefix_chars, - 'fromfile_prefix_chars': fromfile_prefix_chars, - 'argument_default': argument_default, - 'conflict_handler': conflict_handler, - 'add_help': add_help} - - if sys.version_info >= (3, 5): - params['allow_abbrev'] = allow_abbrev - - super().__init__(**params) + 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 = '' diff --git a/docs/requirements.txt b/docs/requirements.txt index 4f05675a4..9f2bbbc3a 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,5 @@ pyparsing pyperclip contextlib2 -enum34 -subprocess32 wcwidth +colorama From 24284828b604f93d658f4d460bf4610e4c3c1550 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Wed, 18 Apr 2018 10:40:46 -0400 Subject: [PATCH 21/26] Changed coverage to look in cmd2 directory. --- .coveragerc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.coveragerc b/.coveragerc index a50fe0e7c..307e3a9a3 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 From 94bea939abd9b701ecfd22af0c1c94b5764ad483 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Wed, 18 Apr 2018 11:56:14 -0400 Subject: [PATCH 22/26] Adjusted some coverage configuration. Found and fixed bug in help completion. --- cmd2/cmd2.py | 14 ++- tests/conftest.py | 47 +++++++ tests/test_autocompletion.py | 57 +-------- tests/test_completion.py | 230 +++++++++++++++++++++++++++-------- tox.ini | 6 +- 5 files changed, 244 insertions(+), 110 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 87db423a1..1c9a3c306 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -271,8 +271,18 @@ def cmd_wrapper(instance, cmdline): # 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 diff --git a/tests/conftest.py b/tests/conftest.py index 837e75047..ed76cba9d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,9 +8,21 @@ 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 ): @@ -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_autocompletion.py b/tests/test_autocompletion.py index 0b33095c5..5f28086a1 100644 --- a/tests/test_autocompletion.py +++ b/tests/test_autocompletion.py @@ -7,31 +7,11 @@ Copyright 2017 Todd Leonhardt Released under MIT license, see LICENSE file """ -import os -import sys - -from unittest import mock import pytest -from .conftest import run_cmd, normalize, StdOut - -MY_PATH = os.path.realpath(__file__) -sys.path.append(os.path.join(MY_PATH, '..', 'examples')) +from .conftest import run_cmd, normalize, StdOut, complete_tester from examples.tab_autocompletion import TabCompleteExample -# 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 - - @pytest.fixture def cmd2_app(): c = TabCompleteExample() @@ -40,41 +20,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 = [] - 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 if not None else [] - - SUGGEST_HELP = '''Usage: suggest -t {movie, show} [-h] [-d DURATION{1..2}] Suggest command demonstrates argparse customizations See hybrid_suggest and diff --git a/tests/test_completion.py b/tests/test_completion.py index 5e76aee6d..2902f55e8 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -13,21 +13,8 @@ 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 @@ -934,6 +886,186 @@ 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(): + 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 88fd47d96..6749418b8 100644 --- a/tox.ini +++ b/tox.ini @@ -20,7 +20,7 @@ deps = pytest-xdist wcwidth commands = - py.test {posargs: -n 2} --cov=cmd2 --cov=AutoCompleter --cov=rl_utils --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=AutoCompleter --cov=rl_utils --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=AutoCompleter --cov=rl_utils --cov-report=term-missing + py.test {posargs: -n 2} --cov --cov-report=term-missing codecov [testenv:py37] From 154fa93acfc3de44bd4d5e15c6704865427ee7ba Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Wed, 18 Apr 2018 13:20:32 -0400 Subject: [PATCH 23/26] Adds main.py to run base cmd2 directly for development testing purposes. --- cmd2/cmd2.py | 7 ------- main.py | 12 ++++++++++++ 2 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 main.py diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 1c9a3c306..bc3bc0894 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -4047,10 +4047,3 @@ def __bool__(self): 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/main.py b/main.py new file mode 100644 index 000000000..a0539bd0b --- /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=False) + app.cmdloop() From 49183003e512ed8e3e107fd4cbc7288a02f77f83 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Wed, 18 Apr 2018 10:46:18 -0700 Subject: [PATCH 24/26] Updated main.py debug/test app to have execute permissions and include ipy command --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) mode change 100644 => 100755 main.py diff --git a/main.py b/main.py old mode 100644 new mode 100755 index a0539bd0b..9e340600a --- a/main.py +++ b/main.py @@ -8,5 +8,5 @@ # 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 = Cmd(use_ipython=True) app.cmdloop() From c2186332aeb6f59063bb410fca25ed400ce410cd Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Thu, 19 Apr 2018 12:13:32 -0400 Subject: [PATCH 25/26] Addresses comments on #362 --- ...AutoCompleter.py => argparse_completer.py} | 229 ++++++++++-------- cmd2/rl_utils.py | 4 +- examples/tab_autocompletion.py | 24 +- tests/test_acargparse.py | 11 +- tests/test_autocompletion.py | 13 +- tests/test_completion.py | 23 +- 6 files changed, 156 insertions(+), 148 deletions(-) rename cmd2/{AutoCompleter.py => argparse_completer.py} (86%) diff --git a/cmd2/AutoCompleter.py b/cmd2/argparse_completer.py similarity index 86% rename from cmd2/AutoCompleter.py rename to cmd2/argparse_completer.py index a8d258956..03cc64834 100755 --- a/cmd2/AutoCompleter.py +++ b/cmd2/argparse_completer.py @@ -1,14 +1,71 @@ # 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 +""" + import argparse -import re as _re +from colorama import Fore import sys -from argparse import OPTIONAL, ZERO_OR_MORE, ONE_OR_MORE, REMAINDER, PARSER, ArgumentError, _ +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 -try: - from typing import List, Dict, Tuple, Callable, Union -except: - pass -from colorama import Fore class _RangeAction(object): @@ -128,57 +185,7 @@ def __init__(self, subcmd_args_lookup: dict = None, tab_for_arg_help: bool = True): """ - 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) - + Create an AutoCompleter :param parser: ArgumentParser instance :param token_start_index: index of the token to start parsing at @@ -200,8 +207,9 @@ def my_completer(text: str, line: str, begidx: int, endidx:int, extra_param: str 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) - self._positional_completers = {} # maps action name to sub-command autocompleter: - # action_name -> dict(sub_command -> completer) + # 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 @@ -225,10 +233,10 @@ def my_completer(text: str, line: str, begidx: int, endidx:int, extra_param: str sub_completers = {} sub_commands = [] args_for_action = subcmd_args_lookup[action.dest]\ - if action.dest in subcmd_args_lookup.keys() else {} + 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.keys() else \ + 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, @@ -258,7 +266,7 @@ 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 len(token) > 0 and not token[0] in self._parser.prefix_chars and flag_action is not None: + 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 @@ -298,10 +306,10 @@ def consume_positional_argument() -> None: flag_action = None # does the token fully match a known flag? - if token in self._flag_to_action.keys(): + 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.keys() if flag.startswith(token)] + 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]] @@ -338,9 +346,9 @@ def consume_positional_argument() -> None: if pos_index < len(self._positional_actions): action = self._positional_actions[pos_index] pos_name = action.dest - if pos_name in self._positional_completers.keys(): + if pos_name in self._positional_completers: sub_completers = self._positional_completers[pos_name] - if token in sub_completers.keys(): + if token in sub_completers: return sub_completers[token].complete_command(tokens, text, line, begidx, endidx) pos_action = action @@ -372,7 +380,7 @@ def consume_positional_argument() -> None: # current_items = [] if flag_action is not None: consumed = consumed_arg_values[flag_action.dest]\ - if flag_action.dest in consumed_arg_values.keys() else [] + 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: @@ -382,7 +390,7 @@ def consume_positional_argument() -> None: 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.keys() else [] + 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) @@ -420,8 +428,8 @@ def _complete_for_arg(self, action: argparse.Action, line: str, begidx: int, endidx: int, - used_values=list()) -> List[str]: - if action.dest in self._arg_choices.keys(): + 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]): @@ -447,8 +455,8 @@ def _complete_for_arg(self, action: argparse.Action, return [] - def _resolve_choices_for_arg(self, action: argparse.Action, used_values=list()) -> List[str]: - if action.dest in self._arg_choices.keys(): + 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() @@ -508,6 +516,14 @@ def basic_complete(text: str, line: str, begidx: int, endidx: int, match_against 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""" @@ -529,8 +545,9 @@ def _format_usage(self, usage, actions, groups, prefix): # split optionals from positionals optionals = [] - required_options = [] 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: @@ -539,6 +556,7 @@ def _format_usage(self, usage, actions, groups, prefix): optionals.append(action) else: positionals.append(action) + # End cmd2 customization # build full usage string format = self._format_actions_usage @@ -549,6 +567,8 @@ def _format_usage(self, usage, actions, groups, prefix): 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) @@ -561,6 +581,8 @@ def _format_usage(self, usage, actions, groups, prefix): 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 = [] @@ -585,6 +607,7 @@ def get_lines(parts, indent, prefix=None): # 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)) @@ -594,10 +617,12 @@ def get_lines(parts, indent, prefix=None): 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: @@ -605,6 +630,7 @@ def get_lines(parts, indent, prefix=None): 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 @@ -628,46 +654,24 @@ def _format_action_invocation(self, action): parts.extend(action.option_strings) return ', '.join(parts) + # Begin cmd2 customization (less verbose) # if the Optional takes a value, format is: - # -s ARGS, --long ARGS + # -s, --long ARGS else: default = self._get_default_metavar_for_optional(action) args_string = self._format_args(action, default) - # for option_string in action.option_strings: - # parts.append('%s %s' % (option_string, args_string)) - return ', '.join(action.option_strings) + ' ' + args_string - - def _format_args(self, action, default_metavar): - get_metavar = self._metavar_formatter(action, default_metavar) - 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 is None: - result = '%s' % get_metavar(1) - elif action.nargs == OPTIONAL: - result = '[%s]' % get_metavar(1) - elif action.nargs == ZERO_OR_MORE: - result = '[%s [...]]' % get_metavar(1) - elif action.nargs == ONE_OR_MORE: - result = '%s [...]' % get_metavar(1) - elif action.nargs == REMAINDER: - result = '...' - elif action.nargs == PARSER: - result = '%s ...' % get_metavar(1) - else: - formats = ['%s' for _ in range(action.nargs)] - result = ' '.join(formats) % get_metavar(action.nargs) - return result + # 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 @@ -678,6 +682,21 @@ def format(tuple_size): 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() @@ -694,15 +713,17 @@ def __init__(self, *args, **kwargs): 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.""" + """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 = '' @@ -733,6 +754,8 @@ def format_help(self): # 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': @@ -762,6 +785,8 @@ def format_help(self): formatter.add_arguments(action_group._group_actions) formatter.end_section() + # End cmd2 customization + # epilog formatter.add_text(self.epilog) diff --git a/cmd2/rl_utils.py b/cmd2/rl_utils.py index 1dc83d152..c00a5784d 100644 --- a/cmd2/rl_utils.py +++ b/cmd2/rl_utils.py @@ -22,13 +22,15 @@ pass -# Check what implementation of readline we are using 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 diff --git a/examples/tab_autocompletion.py b/examples/tab_autocompletion.py index 103706aca..448268d08 100755 --- a/examples/tab_autocompletion.py +++ b/examples/tab_autocompletion.py @@ -7,7 +7,7 @@ from typing import List import cmd2 -from cmd2 import with_argparser, with_category, AutoCompleter +from cmd2 import with_argparser, with_category, argparse_completer class TabCompleteExample(cmd2.Cmd): @@ -91,7 +91,7 @@ def __init__(self): # - 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 = AutoCompleter.ACArgumentParser() + 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', @@ -113,7 +113,7 @@ def do_suggest(self, args) -> None: def complete_suggest(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: """ Adds tab completion to media""" - completer = AutoCompleter.AutoCompleter(TabCompleteExample.suggest_parser, 1) + 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) @@ -125,7 +125,7 @@ def complete_suggest(self, text: str, line: str, begidx: int, endidx: int) -> Li suggest_parser_hybrid = argparse.ArgumentParser() # This registers the custom narg range handling - AutoCompleter.register_custom_actions(suggest_parser_hybrid) + 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', @@ -141,7 +141,7 @@ def do_hybrid_suggest(self, args): def complete_hybrid_suggest(self, text, line, begidx, endidx): """ Adds tab completion to media""" - completer = AutoCompleter.AutoCompleter(TabCompleteExample.suggest_parser_hybrid) + 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) @@ -168,7 +168,7 @@ def do_orig_suggest(self, args) -> None: def complete_orig_suggest(self, text, line, begidx, endidx) -> List[str]: """ Adds tab completion to media""" - completer = AutoCompleter.AutoCompleter(TabCompleteExample.suggest_parser_orig) + 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) @@ -211,7 +211,7 @@ def _do_media_shows(self, args) -> None: print() - media_parser = AutoCompleter.ACArgumentParser(prog='media') + media_parser = argparse_completer.ACArgumentParser(prog='media') media_types_subparsers = media_parser.add_subparsers(title='Media Types', dest='type') @@ -264,7 +264,7 @@ def complete_media(self, text, line, begidx, endidx): choices = {'actor': self.query_actors, # function 'director': TabCompleteExample.static_list_directors # static list } - completer = AutoCompleter.AutoCompleter(TabCompleteExample.media_parser, arg_choices=choices) + 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) @@ -293,11 +293,11 @@ def _query_movie_database(self): def _query_movie_user_library(self): return TabCompleteExample.USER_MOVIE_LIBRARY - def _filter_library(self, text, line, begidx, endidx, full, exclude=[]): + 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 = AutoCompleter.ACArgumentParser(prog='library') + library_parser = argparse_completer.ACArgumentParser(prog='library') library_subcommands = library_parser.add_subparsers(title='Media Types', dest='type') @@ -410,8 +410,8 @@ def complete_library(self, text, line, begidx, endidx): # under the type sub-parser group, there are 2 sub-parsers: 'movie', 'show' library_subcommand_groups = {'type': library_type_params} - completer = AutoCompleter.AutoCompleter(TabCompleteExample.library_parser, - subcmd_args_lookup=library_subcommand_groups) + 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) diff --git a/tests/test_acargparse.py b/tests/test_acargparse.py index 6f40bd42e..be3e8b973 100644 --- a/tests/test_acargparse.py +++ b/tests/test_acargparse.py @@ -1,14 +1,11 @@ """ -Unit/functional testing for readline tab-completion functions in the cmd2.py module. +Unit/functional testing for argparse customizations in cmd2 -These are primarily tests related to readline completer functions which handle tab-completion of cmd2/cmd commands, -file system paths, and shell commands. - -Copyright 2017 Todd Leonhardt +Copyright 2018 Eric Lin Released under MIT license, see LICENSE file """ import pytest -from cmd2.AutoCompleter import ACArgumentParser +from cmd2.argparse_completer import ACArgumentParser def test_acarg_narg_empty_tuple(): @@ -54,5 +51,3 @@ def test_acarg_narg_tuple_zero_base(): 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 index 5f28086a1..e68bc1041 100644 --- a/tests/test_autocompletion.py +++ b/tests/test_autocompletion.py @@ -1,10 +1,7 @@ """ -Unit/functional testing for readline tab-completion functions in the cmd2.py module. +Unit/functional testing for argparse completer in cmd2 -These are primarily tests related to readline completer functions which handle tab-completion of cmd2/cmd commands, -file system paths, and shell commands. - -Copyright 2017 Todd Leonhardt +Copyright 2018 Eric Lin Released under MIT license, see LICENSE file """ import pytest @@ -257,9 +254,3 @@ def test_autcomp_custom_func_list_and_dict_arg(cmd2_app): 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 2902f55e8..a01d11663 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -863,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 @@ -874,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) @@ -886,10 +888,6 @@ def test_subcommand_tab_completion_space_in_text(sc_app): sc_app.completion_matches == ['Ball" '] and \ sc_app.display_matches == ['Space Ball'] - - - - #################################################### @@ -960,6 +958,7 @@ def do_base(self, args): @pytest.fixture def scu_app(): + """Declare test fixture for with_argparser_and_unknown_args""" app = SubcommandsWithUnknownExample() return app @@ -975,6 +974,7 @@ def test_cmd2_subcmd_with_unknown_completion_single_end(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) @@ -984,6 +984,7 @@ def test_cmd2_subcmd_with_unknown_completion_multiple(scu_app): 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) @@ -1001,6 +1002,7 @@ def test_cmd2_help_subcommand_completion_single(scu_app): 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) @@ -1018,6 +1020,7 @@ def test_cmd2_help_subcommand_completion_nomatch(scu_app): 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' @@ -1030,6 +1033,7 @@ def test_subcommand_tab_completion(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 @@ -1041,6 +1045,7 @@ def test_subcommand_tab_completion_with_no_completer(scu_app): 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) @@ -1053,19 +1058,9 @@ def test_subcommand_tab_completion_space_in_text(scu_app): 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. """ From df09c85e8db95622820712f29228dac2dc049935 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Thu, 19 Apr 2018 12:49:24 -0400 Subject: [PATCH 26/26] Identified and marked a few blocks of code that can't be reached during unit tests due to the lack of a real terminal. Some more comments. --- cmd2/argparse_completer.py | 9 +++++++++ cmd2/cmd2.py | 12 ++++++------ cmd2/rl_utils.py | 7 ++++--- examples/tab_autocompletion.py | 6 +++++- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 03cc64834..35f9342b6 100755 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -52,6 +52,9 @@ def my_completer(text: str, line: str, begidx: int, endidx:int, extra_param: str 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 +Released under MIT license, see LICENSE file """ import argparse @@ -262,6 +265,12 @@ def complete_command(self, tokens: List[str], text: str, line: str, begidx: int, 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 diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index bc3bc0894..871b356bd 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1591,7 +1591,7 @@ def _redirect_complete(self, text, line, begidx, endidx, compfunc): 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 @@ -1613,7 +1613,7 @@ def _pad_matches_to_display(matches_to_display): 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. @@ -1664,7 +1664,7 @@ def _display_matches_gnu_readline(self, substitution, matches, longest_match_len # 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. @@ -3344,7 +3344,7 @@ def do_load(self, arglist): # 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 @@ -3371,7 +3371,7 @@ def is_text_file(file_path): # 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. @@ -3381,7 +3381,7 @@ def is_text_file(file_path): # 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 diff --git a/cmd2/rl_utils.py b/cmd2/rl_utils.py index c00a5784d..d5bef1fff 100644 --- a/cmd2/rl_utils.py +++ b/cmd2/rl_utils.py @@ -18,7 +18,7 @@ try: # noinspection PyUnresolvedReferences import readline - except ImportError: + except ImportError: # pragma: no cover pass @@ -51,7 +51,8 @@ def rl_force_redisplay() -> None: """ if not sys.stdout.isatty(): return - if rl_type == RlType.GNU: + + 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() @@ -60,6 +61,6 @@ def rl_force_redisplay() -> None: display_fixed = ctypes.c_int.in_dll(readline_lib, "rl_display_fixed") display_fixed.value = 1 - elif rl_type == RlType.PYREADLINE: + elif rl_type == RlType.PYREADLINE: # pragma: no cover # noinspection PyProtectedMember readline.rl.mode._print_prompt() diff --git a/examples/tab_autocompletion.py b/examples/tab_autocompletion.py index 448268d08..9741dce2c 100755 --- a/examples/tab_autocompletion.py +++ b/examples/tab_autocompletion.py @@ -1,6 +1,10 @@ #!/usr/bin/env python # coding=utf-8 -"""A simple example demonstrating how to use flag and index based tab-completion functions +""" +A example usage of the AutoCompleter + +Copyright 2018 Eric Lin +Released under MIT license, see LICENSE file """ import argparse import itertools