diff options
52 files changed, 1230 insertions, 113 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 5823c8df..dac0756f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * See the [tab_autocompletion.py](https://github.com/python-cmd2/cmd2/blob/master/examples/tab_autocompletion.py) example for a demonstration of how to use this feature * ``cmd2`` no longer depends on the ``six`` module * ``cmd2`` is now a multi-file Python package instead of a single-file module + * New pyscript approach that provides a pythonic interface to commands in the cmd2 application. * Switch command parsing from pyparsing to custom code which utilizes shlex. * The object passed to do_* methods has changed. It no longer is the pyparsing object, it's a new Statement object, which is a subclass of ``str``. The statement object has many attributes which give you access to various components of the parsed input. If you were using anything but the string in your do_* methods, this change will require you to update your code. * ``commentGrammers`` is no longer supported or available. Comments are C-style or python style. @@ -26,6 +27,7 @@ * Alternatively, see the [argparse_example.py](https://github.com/python-cmd2/cmd2/blob/master/examples/argparse_example.py) * Deleted ``cmd_with_subs_completer``, ``get_subcommands``, and ``get_subcommand_completer`` * Replaced by default AutoCompleter implementation for all commands using argparse + * Deleted support for old method of calling application commands with ``cmd()`` and ``self`` * Python 2 no longer supported * ``cmd2`` now supports Python 3.4+ diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 2dd08977..bf6c047f 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -1,5 +1,2 @@ # # -*- coding: utf-8 -*- -# -from .cmd2 import __version__, Cmd, AddSubmenu, CmdResult, categorize -from .cmd2 import with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category diff --git a/cmd2/argcomplete_bridge.py b/cmd2/argcomplete_bridge.py new file mode 100644 index 00000000..583f3345 --- /dev/null +++ b/cmd2/argcomplete_bridge.py @@ -0,0 +1,246 @@ +# coding=utf-8 +"""Hijack the ArgComplete's bash completion handler to return AutoCompleter results""" + +try: + # check if argcomplete is installed + import argcomplete +except ImportError: + # not installed, skip the rest of the file + pass + +else: + # argcomplete is installed + + from contextlib import redirect_stdout + import copy + from io import StringIO + import os + import shlex + import sys + + from . import constants + from . import utils + + + def tokens_for_completion(line, endidx): + """ + Used by tab completion functions to get all tokens through the one being completed + :param line: str - the current input line with leading whitespace removed + :param endidx: int - the ending index of the prefix text + :return: A 4 item tuple where the items are + On Success + tokens: list of unquoted tokens + this is generally the list needed for tab completion functions + raw_tokens: list of tokens with any quotes preserved + this can be used to know if a token was quoted or is missing a closing quote + begidx: beginning of last token + endidx: cursor position + + Both lists are guaranteed to have at least 1 item + The last item in both lists is the token being tab completed + + On Failure + Both items are None + """ + unclosed_quote = '' + quotes_to_try = copy.copy(constants.QUOTES) + + tmp_line = line[:endidx] + tmp_endidx = endidx + + # Parse the line into tokens + while True: + try: + # Use non-POSIX parsing to keep the quotes around the tokens + initial_tokens = shlex.split(tmp_line[:tmp_endidx], posix=False) + + # calculate begidx + if unclosed_quote: + begidx = tmp_line[:tmp_endidx].rfind(initial_tokens[-1]) + 1 + else: + if tmp_endidx > 0 and tmp_line[tmp_endidx - 1] == ' ': + begidx = endidx + else: + begidx = tmp_line[:tmp_endidx].rfind(initial_tokens[-1]) + + # If the cursor is at an empty token outside of a quoted string, + # then that is the token being completed. Add it to the list. + if not unclosed_quote and begidx == tmp_endidx: + initial_tokens.append('') + break + except ValueError: + # ValueError can be caused by missing closing quote + if not quotes_to_try: + # Since we have no more quotes to try, something else + # is causing the parsing error. Return None since + # this means the line is malformed. + return None, None, None, None + + # Add a closing quote and try to parse again + unclosed_quote = quotes_to_try[0] + quotes_to_try = quotes_to_try[1:] + + tmp_line = line[:endidx] + tmp_line += unclosed_quote + tmp_endidx = endidx + 1 + + raw_tokens = initial_tokens + + # Save the unquoted tokens + tokens = [utils.strip_quotes(cur_token) for cur_token in raw_tokens] + + # If the token being completed had an unclosed quote, we need + # to remove the closing quote that was added in order for it + # to match what was on the command line. + if unclosed_quote: + raw_tokens[-1] = raw_tokens[-1][:-1] + + return tokens, raw_tokens, begidx, endidx + + class CompletionFinder(argcomplete.CompletionFinder): + """Hijack the functor from argcomplete to call AutoCompleter""" + + def __call__(self, argument_parser, completer=None, always_complete_options=True, exit_method=os._exit, output_stream=None, + exclude=None, validator=None, print_suppressed=False, append_space=None, + default_completer=argcomplete.FilesCompleter()): + """ + :param argument_parser: The argument parser to autocomplete on + :type argument_parser: :class:`argparse.ArgumentParser` + :param always_complete_options: + Controls the autocompletion of option strings if an option string opening character (normally ``-``) has not + been entered. If ``True`` (default), both short (``-x``) and long (``--x``) option strings will be + suggested. If ``False``, no option strings will be suggested. If ``long``, long options and short options + with no long variant will be suggested. If ``short``, short options and long options with no short variant + will be suggested. + :type always_complete_options: boolean or string + :param exit_method: + Method used to stop the program after printing completions. Defaults to :meth:`os._exit`. If you want to + perform a normal exit that calls exit handlers, use :meth:`sys.exit`. + :type exit_method: callable + :param exclude: List of strings representing options to be omitted from autocompletion + :type exclude: iterable + :param validator: + Function to filter all completions through before returning (called with two string arguments, completion + and prefix; return value is evaluated as a boolean) + :type validator: callable + :param print_suppressed: + Whether or not to autocomplete options that have the ``help=argparse.SUPPRESS`` keyword argument set. + :type print_suppressed: boolean + :param append_space: + Whether to append a space to unique matches. The default is ``True``. + :type append_space: boolean + + .. note:: + If you are not subclassing CompletionFinder to override its behaviors, + use ``argcomplete.autocomplete()`` directly. It has the same signature as this method. + + Produces tab completions for ``argument_parser``. See module docs for more info. + + Argcomplete only executes actions if their class is known not to have side effects. Custom action classes can be + added to argcomplete.safe_actions, if their values are wanted in the ``parsed_args`` completer argument, or + their execution is otherwise desirable. + """ + self.__init__(argument_parser, always_complete_options=always_complete_options, exclude=exclude, + validator=validator, print_suppressed=print_suppressed, append_space=append_space, + default_completer=default_completer) + + if "_ARGCOMPLETE" not in os.environ: + # not an argument completion invocation + return + + try: + argcomplete.debug_stream = os.fdopen(9, "w") + except IOError: + argcomplete.debug_stream = sys.stderr + + if output_stream is None: + try: + output_stream = os.fdopen(8, "wb") + except IOError: + argcomplete.debug("Unable to open fd 8 for writing, quitting") + exit_method(1) + + # print("", stream=debug_stream) + # for v in "COMP_CWORD COMP_LINE COMP_POINT COMP_TYPE COMP_KEY _ARGCOMPLETE_COMP_WORDBREAKS COMP_WORDS".split(): + # print(v, os.environ[v], stream=debug_stream) + + ifs = os.environ.get("_ARGCOMPLETE_IFS", "\013") + if len(ifs) != 1: + argcomplete.debug("Invalid value for IFS, quitting [{v}]".format(v=ifs)) + exit_method(1) + + comp_line = os.environ["COMP_LINE"] + comp_point = int(os.environ["COMP_POINT"]) + + comp_line = argcomplete.ensure_str(comp_line) + + ############################## + # SWAPPED FOR AUTOCOMPLETER + # + # Replaced with our own tokenizer function + ############################## + + # cword_prequote, cword_prefix, cword_suffix, comp_words, last_wordbreak_pos = split_line(comp_line, comp_point) + tokens, _, begidx, endidx = tokens_for_completion(comp_line, comp_point) + + # _ARGCOMPLETE is set by the shell script to tell us where comp_words + # should start, based on what we're completing. + # 1: <script> [args] + # 2: python <script> [args] + # 3: python -m <module> [args] + start = int(os.environ["_ARGCOMPLETE"]) - 1 + ############################## + # SWAPPED FOR AUTOCOMPLETER + # + # Applying the same token dropping to our tokens + ############################## + # comp_words = comp_words[start:] + tokens = tokens[start:] + + # debug("\nLINE: {!r}".format(comp_line), + # "\nPOINT: {!r}".format(comp_point), + # "\nPREQUOTE: {!r}".format(cword_prequote), + # "\nPREFIX: {!r}".format(cword_prefix), + # "\nSUFFIX: {!r}".format(cword_suffix), + # "\nWORDS:", comp_words) + + ############################## + # SWAPPED FOR AUTOCOMPLETER + # + # Replaced with our own completion function and customizing the returned values + ############################## + # completions = self._get_completions(comp_words, cword_prefix, cword_prequote, last_wordbreak_pos) + + # capture stdout from the autocompleter + result = StringIO() + with redirect_stdout(result): + completions = completer.complete_command(tokens, tokens[-1], comp_line, begidx, endidx) + outstr = result.getvalue() + + if completions: + # If any completion has a space in it, then quote all completions + # this improves the user experience so they don't nede to go back and add a quote + if ' ' in ''.join(completions): + completions = ['"{}"'.format(entry) for entry in completions] + + argcomplete.debug("\nReturning completions:", completions) + + output_stream.write(ifs.join(completions).encode(argcomplete.sys_encoding)) + elif outstr: + # if there are no completions, but we got something from stdout, try to print help + + # trick the bash completion into thinking there are 2 completions that are unlikely + # to ever match. + outstr = outstr.replace('\n', ' ').replace('\t', ' ').replace(' ', ' ').strip() + # generate a filler entry that should always sort first + filler = ' {0:><{width}}'.format('', width=len(outstr)/2) + outstr = ifs.join([filler, outstr]) + + output_stream.write(outstr.encode(argcomplete.sys_encoding)) + else: + # if completions is None we assume we don't know how to handle it so let bash + # go forward with normal filesystem completion + output_stream.write(ifs.join([]).encode(argcomplete.sys_encoding)) + output_stream.flush() + argcomplete.debug_stream.flush() + exit_method(0) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 03f2d965..4964b1ec 100755 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -64,7 +64,8 @@ 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, _ +from argparse import ZERO_OR_MORE, ONE_OR_MORE, ArgumentError, _, _get_action_name, SUPPRESS + import re as _re @@ -75,6 +76,7 @@ from .rl_utils import rl_force_redisplay # define the completion choices for the argument. You may provide a Collection or a Function. ACTION_ARG_CHOICES = 'arg_choices' + class _RangeAction(object): def __init__(self, nargs: Union[int, str, Tuple[int, int], None]): self.nargs_min = None @@ -103,6 +105,7 @@ class _RangeAction(object): self.nargs_adjusted = nargs +# noinspection PyShadowingBuiltins,PyShadowingBuiltins class _StoreRangeAction(argparse._StoreAction, _RangeAction): def __init__(self, option_strings, @@ -131,6 +134,7 @@ class _StoreRangeAction(argparse._StoreAction, _RangeAction): metavar=metavar) +# noinspection PyShadowingBuiltins,PyShadowingBuiltins class _AppendRangeAction(argparse._AppendAction, _RangeAction): def __init__(self, option_strings, @@ -433,7 +437,6 @@ class AutoCompleter(object): return self.basic_complete(text, line, begidx, endidx, completers.keys()) return [] - @staticmethod def _process_action_nargs(action: argparse.Action, arg_state: _ArgumentState) -> None: if isinstance(action, _RangeAction): @@ -536,7 +539,10 @@ class AutoCompleter(object): prefix = ' {0: <{width}} '.format(prefix, width=20) pref_len = len(prefix) - help_lines = action.help.splitlines() + if action.help is not None: + help_lines = action.help.splitlines() + else: + help_lines = [''] if len(help_lines) == 1: print('\nHint:\n{}{}\n'.format(prefix, help_lines[0])) else: @@ -571,6 +577,7 @@ class AutoCompleter(object): ############################################################################### +# noinspection PyCompatibility,PyShadowingBuiltins,PyShadowingBuiltins class ACHelpFormatter(argparse.HelpFormatter): """Custom help formatter to configure ordering of help text""" @@ -631,6 +638,7 @@ class ACHelpFormatter(argparse.HelpFormatter): # End cmd2 customization # helper for wrapping lines + # noinspection PyMissingOrEmptyDocstring,PyShadowingNames def get_lines(parts, indent, prefix=None): lines = [] line = [] @@ -722,6 +730,7 @@ class ACHelpFormatter(argparse.HelpFormatter): else: result = default_metavar + # noinspection PyMissingOrEmptyDocstring def format(tuple_size): if isinstance(result, tuple): return result @@ -748,6 +757,7 @@ class ACHelpFormatter(argparse.HelpFormatter): return text.splitlines() +# noinspection PyCompatibility class ACArgumentParser(argparse.ArgumentParser): """Custom argparse class to override error method to change default help text.""" @@ -866,3 +876,259 @@ class ACArgumentParser(argparse.ArgumentParser): 'Expected between {} and {} arguments'.format(action.nargs_min, action.nargs_max)) return super(ACArgumentParser, self)._match_argument(action, arg_strings_pattern) + + def _parse_known_args(self, arg_strings, namespace): + # replace arg strings that are file references + if self.fromfile_prefix_chars is not None: + arg_strings = self._read_args_from_files(arg_strings) + + # map all mutually exclusive arguments to the other arguments + # they can't occur with + action_conflicts = {} + for mutex_group in self._mutually_exclusive_groups: + group_actions = mutex_group._group_actions + for i, mutex_action in enumerate(mutex_group._group_actions): + conflicts = action_conflicts.setdefault(mutex_action, []) + conflicts.extend(group_actions[:i]) + conflicts.extend(group_actions[i + 1:]) + + # find all option indices, and determine the arg_string_pattern + # which has an 'O' if there is an option at an index, + # an 'A' if there is an argument, or a '-' if there is a '--' + option_string_indices = {} + arg_string_pattern_parts = [] + arg_strings_iter = iter(arg_strings) + for i, arg_string in enumerate(arg_strings_iter): + + # all args after -- are non-options + if arg_string == '--': + arg_string_pattern_parts.append('-') + for arg_string in arg_strings_iter: + arg_string_pattern_parts.append('A') + + # otherwise, add the arg to the arg strings + # and note the index if it was an option + else: + option_tuple = self._parse_optional(arg_string) + if option_tuple is None: + pattern = 'A' + else: + option_string_indices[i] = option_tuple + pattern = 'O' + arg_string_pattern_parts.append(pattern) + + # join the pieces together to form the pattern + arg_strings_pattern = ''.join(arg_string_pattern_parts) + + # converts arg strings to the appropriate and then takes the action + seen_actions = set() + seen_non_default_actions = set() + + def take_action(action, argument_strings, option_string=None): + seen_actions.add(action) + argument_values = self._get_values(action, argument_strings) + + # error if this argument is not allowed with other previously + # seen arguments, assuming that actions that use the default + # value don't really count as "present" + if argument_values is not action.default: + seen_non_default_actions.add(action) + for conflict_action in action_conflicts.get(action, []): + if conflict_action in seen_non_default_actions: + msg = _('not allowed with argument %s') + action_name = _get_action_name(conflict_action) + raise ArgumentError(action, msg % action_name) + + # take the action if we didn't receive a SUPPRESS value + # (e.g. from a default) + if argument_values is not SUPPRESS: + action(self, namespace, argument_values, option_string) + + # function to convert arg_strings into an optional action + def consume_optional(start_index): + + # get the optional identified at this index + option_tuple = option_string_indices[start_index] + action, option_string, explicit_arg = option_tuple + + # identify additional optionals in the same arg string + # (e.g. -xyz is the same as -x -y -z if no args are required) + match_argument = self._match_argument + action_tuples = [] + while True: + + # if we found no optional action, skip it + if action is None: + extras.append(arg_strings[start_index]) + return start_index + 1 + + # if there is an explicit argument, try to match the + # optional's string arguments to only this + if explicit_arg is not None: + arg_count = match_argument(action, 'A') + + # if the action is a single-dash option and takes no + # arguments, try to parse more single-dash options out + # of the tail of the option string + chars = self.prefix_chars + if arg_count == 0 and option_string[1] not in chars: + action_tuples.append((action, [], option_string)) + char = option_string[0] + option_string = char + explicit_arg[0] + new_explicit_arg = explicit_arg[1:] or None + optionals_map = self._option_string_actions + if option_string in optionals_map: + action = optionals_map[option_string] + explicit_arg = new_explicit_arg + else: + msg = _('ignored explicit argument %r') + raise ArgumentError(action, msg % explicit_arg) + + # if the action expect exactly one argument, we've + # successfully matched the option; exit the loop + elif arg_count == 1: + stop = start_index + 1 + args = [explicit_arg] + action_tuples.append((action, args, option_string)) + break + + # error if a double-dash option did not use the + # explicit argument + else: + msg = _('ignored explicit argument %r') + raise ArgumentError(action, msg % explicit_arg) + + # if there is no explicit argument, try to match the + # optional's string arguments with the following strings + # if successful, exit the loop + else: + start = start_index + 1 + selected_patterns = arg_strings_pattern[start:] + arg_count = match_argument(action, selected_patterns) + stop = start + arg_count + args = arg_strings[start:stop] + action_tuples.append((action, args, option_string)) + break + + # add the Optional to the list and return the index at which + # the Optional's string args stopped + assert action_tuples + for action, args, option_string in action_tuples: + take_action(action, args, option_string) + return stop + + # the list of Positionals left to be parsed; this is modified + # by consume_positionals() + positionals = self._get_positional_actions() + + # function to convert arg_strings into positional actions + def consume_positionals(start_index): + # match as many Positionals as possible + match_partial = self._match_arguments_partial + selected_pattern = arg_strings_pattern[start_index:] + arg_counts = match_partial(positionals, selected_pattern) + + #################################################################### + # Applied mixed.patch from https://bugs.python.org/issue15112 + if 'O' in arg_strings_pattern[start_index:]: + # if there is an optional after this, remove + # 'empty' positionals from the current match + + while len(arg_counts) > 1 and arg_counts[-1] == 0: + arg_counts = arg_counts[:-1] + #################################################################### + + # slice off the appropriate arg strings for each Positional + # and add the Positional and its args to the list + for action, arg_count in zip(positionals, arg_counts): + args = arg_strings[start_index: start_index + arg_count] + start_index += arg_count + take_action(action, args) + + # slice off the Positionals that we just parsed and return the + # index at which the Positionals' string args stopped + positionals[:] = positionals[len(arg_counts):] + return start_index + + # consume Positionals and Optionals alternately, until we have + # passed the last option string + extras = [] + start_index = 0 + if option_string_indices: + max_option_string_index = max(option_string_indices) + else: + max_option_string_index = -1 + while start_index <= max_option_string_index: + + # consume any Positionals preceding the next option + next_option_string_index = min([ + index + for index in option_string_indices + if index >= start_index]) + if start_index != next_option_string_index: + positionals_end_index = consume_positionals(start_index) + + # only try to parse the next optional if we didn't consume + # the option string during the positionals parsing + if positionals_end_index > start_index: + start_index = positionals_end_index + continue + else: + start_index = positionals_end_index + + # if we consumed all the positionals we could and we're not + # at the index of an option string, there were extra arguments + if start_index not in option_string_indices: + strings = arg_strings[start_index:next_option_string_index] + extras.extend(strings) + start_index = next_option_string_index + + # consume the next optional and any arguments for it + start_index = consume_optional(start_index) + + # consume any positionals following the last Optional + stop_index = consume_positionals(start_index) + + # if we didn't consume all the argument strings, there were extras + extras.extend(arg_strings[stop_index:]) + + # make sure all required actions were present and also convert + # action defaults which were not given as arguments + required_actions = [] + for action in self._actions: + if action not in seen_actions: + if action.required: + required_actions.append(_get_action_name(action)) + else: + # Convert action default now instead of doing it before + # parsing arguments to avoid calling convert functions + # twice (which may fail) if the argument was given, but + # only if it was defined already in the namespace + if (action.default is not None and + isinstance(action.default, str) and + hasattr(namespace, action.dest) and + action.default is getattr(namespace, action.dest)): + setattr(namespace, action.dest, + self._get_value(action, action.default)) + + if required_actions: + self.error(_('the following arguments are required: %s') % + ', '.join(required_actions)) + + # make sure all required groups had one option present + for group in self._mutually_exclusive_groups: + if group.required: + for action in group._group_actions: + if action in seen_non_default_actions: + break + + # if no actions were used, report the error + else: + names = [_get_action_name(action) + for action in group._group_actions + if action.help is not SUPPRESS] + msg = _('one of the arguments %s is required') + self.error(msg % ' '.join(names)) + + # return the updated namespace and the extra arguments + return namespace, extras diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index e99e4659..f4f30bd4 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -197,8 +197,12 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser) -> Calla @functools.wraps(func) def cmd_wrapper(instance, cmdline): lexed_arglist = parse_quoted_string(cmdline) - args, unknown = argparser.parse_known_args(lexed_arglist) - return func(instance, args, unknown) + try: + args, unknown = argparser.parse_known_args(lexed_arglist) + except SystemExit: + return + else: + return func(instance, args, unknown) # argparser defaults the program name to sys.argv[0] # we want it to be the name of our command @@ -234,8 +238,12 @@ def with_argparser(argparser: argparse.ArgumentParser) -> Callable: @functools.wraps(func) def cmd_wrapper(instance, cmdline): lexed_arglist = parse_quoted_string(cmdline) - args = argparser.parse_args(lexed_arglist) - return func(instance, args) + try: + args = argparser.parse_args(lexed_arglist) + except SystemExit: + return + else: + return func(instance, args) # argparser defaults the program name to sys.argv[0] # we want it to be the name of our command @@ -608,7 +616,7 @@ class Cmd(cmd.Cmd): if _which(editor): break feedback_to_output = False # Do not include nonessentials in >, | output by default (things like timing) - locals_in_py = True + locals_in_py = False quiet = False # Do not suppress nonessential output timing = False # Prints elapsed time for each command @@ -670,6 +678,7 @@ class Cmd(cmd.Cmd): self.initial_stdout = sys.stdout self.history = History() self.pystate = {} + self.pyscript_name = 'app' self.keywords = self.reserved_words + [fname[3:] for fname in dir(self) if fname.startswith('do_')] self.statement_parser = StatementParser( allow_redirection=self.allow_redirection, @@ -1993,6 +2002,8 @@ class Cmd(cmd.Cmd): if self.allow_redirection: self._redirect_output(statement) timestart = datetime.datetime.now() + if self._in_py: + self._last_result = None statement = self.precmd(statement) stop = self.onecmd(statement) stop = self.postcmd(stop, statement) @@ -2797,16 +2808,16 @@ Usage: Usage: unalias [-a] name [name ...] py <command>: Executes a Python command. py: Enters interactive Python mode. End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``. - Non-python commands can be issued with ``cmd("your command")``. + Non-python commands can be issued with ``pyscript_name("your command")``. Run python code from external script files with ``run("script.py")`` """ + from .pyscript_bridge import PyscriptBridge if self._in_py: self.perror("Recursively entering interactive Python consoles is not allowed.", traceback_war=False) return self._in_py = True try: - self.pystate['self'] = self arg = arg.strip() # Support the run command even if called prior to invoking an interactive interpreter @@ -2829,10 +2840,14 @@ Usage: Usage: unalias [-a] name [name ...] """ return self.onecmd_plus_hooks(cmd_plus_args + '\n') + bridge = PyscriptBridge(self) self.pystate['run'] = run - self.pystate['cmd'] = onecmd_plus_hooks + self.pystate[self.pyscript_name] = bridge + + if self.locals_in_py: + self.pystate['self'] = self - localvars = (self.locals_in_py and self.pystate) or {} + localvars = self.pystate interp = InteractiveConsole(locals=localvars) interp.runcode('import sys, os;sys.path.insert(0, os.getcwd())') @@ -2853,9 +2868,10 @@ Usage: Usage: unalias [-a] name [name ...] keepstate = Statekeeper(sys, ('stdin', 'stdout')) sys.stdout = self.stdout sys.stdin = self.stdin + docstr = self.do_py.__doc__.replace('pyscript_name', self.pyscript_name) interp.interact(banner="Python %s on %s\n%s\n(%s)\n%s" % (sys.version, sys.platform, cprt, self.__class__.__name__, - self.do_py.__doc__)) + docstr)) except EmbeddedConsoleExit: pass if keepstate is not None: diff --git a/cmd2/pyscript_bridge.py b/cmd2/pyscript_bridge.py new file mode 100644 index 00000000..055ae4ae --- /dev/null +++ b/cmd2/pyscript_bridge.py @@ -0,0 +1,273 @@ +# coding=utf-8 +""" +Bridges calls made inside of pyscript with the Cmd2 host app while maintaining a reasonable +degree of isolation between the two + +Copyright 2018 Eric Lin <anselor@gmail.com> +Released under MIT license, see LICENSE file +""" + +import argparse +from collections import namedtuple +import functools +import sys +from typing import List, Tuple + +# Python 3.4 require contextlib2 for temporarily redirecting stderr and stdout +if sys.version_info < (3, 5): + from contextlib2 import redirect_stdout, redirect_stderr +else: + from contextlib import redirect_stdout, redirect_stderr + +from .argparse_completer import _RangeAction +from .utils import namedtuple_with_defaults + + +class CommandResult(namedtuple_with_defaults('CmdResult', ['stdout', 'stderr', 'data'])): + """Encapsulates the results from a command. + + Named tuple attributes + ---------------------- + stdout: str - Output captured from stdout while this command is executing + stderr: str - Output captured from stderr while this command is executing. None if no error captured + data - Data returned by the command. + + NOTE: Named tuples are immutable. So the contents are there for access, not for modification. + """ + def __bool__(self): + """If stderr is None and data is not None the command is considered a success""" + return not self.stderr and self.data is not None + + +class CopyStream(object): + """Copies all data written to a stream""" + def __init__(self, inner_stream): + self.buffer = '' + self.inner_stream = inner_stream + + def write(self, s): + self.buffer += s + self.inner_stream.write(s) + + def read(self): + raise NotImplementedError + + def clear(self): + self.buffer = '' + + +def _exec_cmd(cmd2_app, func): + """Helper to encapsulate executing a command and capturing the results""" + copy_stdout = CopyStream(sys.stdout) + copy_stderr = CopyStream(sys.stderr) + + cmd2_app._last_result = None + + with redirect_stdout(copy_stdout): + with redirect_stderr(copy_stderr): + func() + + # if stderr is empty, set it to None + stderr = copy_stderr if copy_stderr.buffer else None + + result = CommandResult(stdout=copy_stdout.buffer, stderr=stderr, data=cmd2_app._last_result) + return result + + +class ArgparseFunctor: + """ + Encapsulates translating python object traversal + """ + def __init__(self, cmd2_app, item, parser): + self._cmd2_app = cmd2_app + self._item = item + self._parser = parser + + # Dictionary mapping command argument name to value + self._args = {} + # argparse object for the current command layer + self.__current_subcommand_parser = parser + + def __getattr__(self, item): + """Search for a subcommand matching this item and update internal state to track the traversal""" + # look for sub-command under the current command/sub-command layer + for action in self.__current_subcommand_parser._actions: + if not action.option_strings and isinstance(action, argparse._SubParsersAction): + if item in action.choices: + # item matches the a sub-command, save our position in argparse, + # save the sub-command, return self to allow next level of traversal + self.__current_subcommand_parser = action.choices[item] + self._args[action.dest] = item + return self + + raise AttributeError(item) + # return super().__getattr__(item) + + def __call__(self, *args, **kwargs): + """ + Process the arguments at this layer of the argparse command tree. If there are more sub-commands, + return self to accept the next sub-command name. If there are no more sub-commands, execute the + sub-command with the given parameters. + """ + next_pos_index = 0 + + has_subcommand = False + consumed_kw = [] + + # Iterate through the current sub-command's arguments in order + for action in self.__current_subcommand_parser._actions: + # is this a flag option? + if action.option_strings: + # this is a flag argument, search for the argument by name in the parameters + if action.dest in kwargs: + self._args[action.dest] = kwargs[action.dest] + consumed_kw.append(action.dest) + else: + # This is a positional argument, search the positional arguments passed in. + if not isinstance(action, argparse._SubParsersAction): + if action.dest in kwargs: + # if this positional argument happens to be passed in as a keyword argument + # go ahead and consume the matching keyword argument + self._args[action.dest] = kwargs[action.dest] + elif next_pos_index < len(args): + # Make sure we actually have positional arguments to consume + pos_remain = len(args) - next_pos_index + + # Check if this argument consumes a range of values + if isinstance(action, _RangeAction) and action.nargs_min is not None \ + and action.nargs_max is not None: + # this is a cmd2 ranged action. + + if pos_remain >= action.nargs_min: + # Do we meet the minimum count? + if pos_remain > action.nargs_max: + # Do we exceed the maximum count? + self._args[action.dest] = args[next_pos_index:next_pos_index + action.nargs_max] + next_pos_index += action.nargs_max + else: + self._args[action.dest] = args[next_pos_index:next_pos_index + pos_remain] + next_pos_index += pos_remain + else: + raise ValueError('Expected at least {} values for {}'.format(action.nargs_min, + action.dest)) + elif action.nargs is not None: + if action.nargs == '+': + if pos_remain > 0: + self._args[action.dest] = args[next_pos_index:next_pos_index + pos_remain] + next_pos_index += pos_remain + else: + raise ValueError('Expected at least 1 value for {}'.format(action.dest)) + elif action.nargs == '*': + self._args[action.dest] = args[next_pos_index:next_pos_index + pos_remain] + next_pos_index += pos_remain + elif action.nargs == '?': + self._args[action.dest] = args[next_pos_index] + next_pos_index += 1 + else: + self._args[action.dest] = args[next_pos_index] + next_pos_index += 1 + else: + has_subcommand = True + + # Check if there are any extra arguments we don't know how to handle + for kw in kwargs: + if kw not in self._args: # consumed_kw: + raise TypeError('{}() got an unexpected keyword argument \'{}\''.format( + self.__current_subcommand_parser.prog, kw)) + + if has_subcommand: + return self + else: + return self._run() + + def _run(self): + # look up command function + func = getattr(self._cmd2_app, 'do_' + self._item) + + # reconstruct the cmd2 command from the python call + cmd_str = [''] + + def process_flag(action, value): + if isinstance(action, argparse._CountAction): + if isinstance(value, int): + for c in range(value): + cmd_str[0] += '{} '.format(action.option_strings[0]) + return + else: + raise TypeError('Expected int for ' + action.dest) + if isinstance(action, argparse._StoreConstAction) or isinstance(action, argparse._AppendConstAction): + if value: + # Nothing else to append to the command string, just the flag is enough. + cmd_str[0] += '{} '.format(action.option_strings[0]) + return + else: + # value is not True so we default to false, which means don't include the flag + return + + # was the argument a flag? + if action.option_strings: + cmd_str[0] += '{} '.format(action.option_strings[0]) + + if isinstance(value, List) or isinstance(value, Tuple): + for item in value: + item = str(item).strip() + if ' ' in item: + item = '"{}"'.format(item) + cmd_str[0] += '{} '.format(item) + else: + value = str(value).strip() + if ' ' in value: + value = '"{}"'.format(value) + cmd_str[0] += '{} '.format(value) + + def traverse_parser(parser): + for action in parser._actions: + # was something provided for the argument + if action.dest in self._args: + if isinstance(action, argparse._SubParsersAction): + cmd_str[0] += '{} '.format(self._args[action.dest]) + traverse_parser(action.choices[self._args[action.dest]]) + elif isinstance(action, argparse._AppendAction): + if isinstance(self._args[action.dest], List) or isinstance(self._args[action.dest], Tuple): + for values in self._args[action.dest]: + process_flag(action, values) + else: + process_flag(action, self._args[action.dest]) + else: + process_flag(action, self._args[action.dest]) + + traverse_parser(self._parser) + + # print('Command: {}'.format(cmd_str[0])) + + return _exec_cmd(self._cmd2_app, functools.partial(func, cmd_str[0])) + +class PyscriptBridge(object): + """Preserves the legacy 'cmd' interface for pyscript while also providing a new python API wrapper for + application commands.""" + def __init__(self, cmd2_app): + self._cmd2_app = cmd2_app + self._last_result = None + + def __getattr__(self, item: str): + """Check if the attribute is a command. If so, return a callable.""" + commands = self._cmd2_app.get_all_commands() + if item in commands: + func = getattr(self._cmd2_app, 'do_' + item) + + try: + # See if the command uses argparse + parser = getattr(func, 'argparser') + except AttributeError: + # Command doesn't, we will accept parameters in the form of a command string + def wrap_func(args=''): + return _exec_cmd(self._cmd2_app, functools.partial(func, args)) + return wrap_func + else: + # Command does use argparse, return an object that can traverse the argparse subcommands and arguments + return ArgparseFunctor(self._cmd2_app, item, parser) + + raise AttributeError(item) + + def __call__(self, args): + return _exec_cmd(self._cmd2_app, functools.partial(self._cmd2_app.onecmd_plus_hooks, args + '\n')) diff --git a/cmd2/utils.py b/cmd2/utils.py index 6abab94c..dbe39213 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -2,6 +2,7 @@ # coding=utf-8 """Shared utility functions""" +import collections from . import constants def strip_ansi(text: str) -> str: @@ -12,6 +13,7 @@ def strip_ansi(text: str) -> str: """ return constants.ANSI_ESCAPE_RE.sub('', text) + def strip_quotes(arg: str) -> str: """ Strip outer quotes from a string. @@ -23,3 +25,33 @@ def strip_quotes(arg: str) -> str: if len(arg) > 1 and arg[0] == arg[-1] and arg[0] in constants.QUOTES: arg = arg[1:-1] return arg + + +def namedtuple_with_defaults(typename, field_names, default_values=()): + """ + Convenience function for defining a namedtuple with default values + + From: https://stackoverflow.com/questions/11351032/namedtuple-and-default-values-for-optional-keyword-arguments + + Examples: + >>> Node = namedtuple_with_defaults('Node', 'val left right') + >>> Node() + Node(val=None, left=None, right=None) + >>> Node = namedtuple_with_defaults('Node', 'val left right', [1, 2, 3]) + >>> Node() + Node(val=1, left=2, right=3) + >>> Node = namedtuple_with_defaults('Node', 'val left right', {'right':7}) + >>> Node() + Node(val=None, left=None, right=7) + >>> Node(4) + Node(val=4, left=None, right=7) + """ + T = collections.namedtuple(typename, field_names) + T.__new__.__defaults__ = (None,) * len(T._fields) + if isinstance(default_values, collections.Mapping): + prototype = T(**default_values) + else: + prototype = T(*default_values) + T.__new__.__defaults__ = tuple(prototype) + return T + diff --git a/docs/freefeatures.rst b/docs/freefeatures.rst index b3f038b0..156b003e 100644 --- a/docs/freefeatures.rst +++ b/docs/freefeatures.rst @@ -142,35 +142,68 @@ app's value of ``self.redirector`` to use a different string for output redirect Python ====== -The ``py`` command will run its arguments as a Python -command. Entered without arguments, it enters an -interactive Python session. That session can call -"back" to your application with ``cmd("")``. Through -``self``, it also has access to your application -instance itself which can be extremely useful for debugging. -(If giving end-users this level of introspection is inappropriate, -the ``locals_in_py`` parameter can be set to ``False`` and removed -from the settable dictionary. See see :ref:`parameters`) +The ``py`` command will run its arguments as a Python command. Entered without +arguments, it enters an interactive Python session. The session can call "back" +to your application through the name defined in ``self.pyscript_name`` (defaults +to ``app``). This wrapper provides access to execute commands in your cmd2 +application while maintaining isolation. + +You may optionally enable full access to to your application by setting +``locals_in_py`` to ``True``. Enabling this flag adds ``self`` to the python +session, which is a reference to your Cmd2 application. This can be useful for +debugging your application. To prevent users from enabling this ability +manually you'll need to remove ``locals_in_py`` from the ``settable`` dictionary. +That session can call + +The ``app`` object (or your custom name) provides access to application commands +through either raw commands or through a python API wrapper. For example, any +application command call be called with ``app("<command>")``. All application +commands are accessible as python objects and functions matching the command +name. For example, the following are equivalent: + +:: + + >>> app('say --piglatin Blah') + lahBay + >>> app.say("Blah", piglatin=True) + lahBay + + +Sub-commands are also supported. The following pairs are equivalent: + +:: + + >>> app('command subcmd1 subcmd2 param1 --myflag --otherflag 3') + >>> app.command.subcmd1.subcmd2('param1', myflag=True, otherflag=3) + + >>> app('command subcmd1 param1 subcmd2 param2 --myflag --otherflag 3') + >>> app.command.subcmd1('param1').subcmd2('param2', myflag=True, otherflag=3) + + +More Python examples: :: (Cmd) py print("-".join("spelling")) s-p-e-l-l-i-n-g (Cmd) py - Python 2.6.4 (r264:75706, Dec 7 2009, 18:45:15) - [GCC 4.4.1] on linux2 + Python 3.5.3 (default, Jan 19 2017, 14:11:04) + [GCC 6.3.0 20170118] on linux Type "help", "copyright", "credits" or "license" for more information. (CmdLineApp) + Invoke python command, shell, or script + py <command>: Executes a Python command. py: Enters interactive Python mode. - End with `Ctrl-D` (Unix) / `Ctrl-Z` (Windows), `quit()`, 'exit()`. - Non-python commands can be issued with `cmd("your command")`. + End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``. + Non-python commands can be issued with ``app("your command")``. + Run python code from external script files with ``run("script.py")`` >>> import os >>> os.uname() ('Linux', 'eee', '2.6.31-19-generic', '#56-Ubuntu SMP Thu Jan 28 01:26:53 UTC 2010', 'i686') - >>> cmd("say --piglatin {os}".format(os=os.uname()[0])) + >>> app("say --piglatin {os}".format(os=os.uname()[0])) inuxLay >>> self.prompt '(Cmd) ' diff --git a/docs/settingchanges.rst b/docs/settingchanges.rst index f005fb49..02955273 100644 --- a/docs/settingchanges.rst +++ b/docs/settingchanges.rst @@ -143,7 +143,7 @@ with:: echo: False # Echo command issued into output editor: vim # Program used by ``edit`` feedback_to_output: False # include nonessentials in `|`, `>` results - locals_in_py: True # Allow access to your application in py via self + locals_in_py: False # Allow access to your application in py via self prompt: (Cmd) # The prompt issued to solicit input quiet: False # Don't print nonessential feedback timing: False # Report execution times diff --git a/examples/alias_startup.py b/examples/alias_startup.py index 30764c27..7ccfa6e5 100755 --- a/examples/alias_startup.py +++ b/examples/alias_startup.py @@ -4,12 +4,8 @@ 1) How to add custom command aliases using the alias command 2) How to load an initialization script at startup """ -import argparse -import cmd2 -import pyparsing - -from cmd2 import with_argument_list, with_argparser, with_argparser_and_unknown_args +from cmd2 import cmd2 class AliasAndStartup(cmd2.Cmd): diff --git a/examples/arg_print.py b/examples/arg_print.py index 95e8ff01..b2f0fcda 100755 --- a/examples/arg_print.py +++ b/examples/arg_print.py @@ -11,10 +11,8 @@ It also serves as an example of how to create command aliases (shortcuts). """ import argparse -import cmd2 -import pyparsing - -from cmd2 import with_argument_list, with_argparser, with_argparser_and_unknown_args +from cmd2 import cmd2 +from cmd2.cmd2 import with_argument_list, with_argparser, with_argparser_and_unknown_args class ArgumentAndOptionPrinter(cmd2.Cmd): diff --git a/examples/argparse_example.py b/examples/argparse_example.py index 3d436323..6e5dcf35 100755 --- a/examples/argparse_example.py +++ b/examples/argparse_example.py @@ -14,7 +14,7 @@ verifying that the output produced matches the transcript. import argparse import sys -from cmd2 import Cmd, with_argparser, with_argument_list +from cmd2.cmd2 import Cmd, with_argparser, with_argument_list class CmdLineApp(Cmd): diff --git a/examples/environment.py b/examples/environment.py index c245f55d..af452e4e 100755 --- a/examples/environment.py +++ b/examples/environment.py @@ -4,7 +4,7 @@ A sample application for cmd2 demonstrating customized environment parameters """ -from cmd2 import Cmd +from cmd2.cmd2 import Cmd class EnvironmentApp(Cmd): diff --git a/examples/event_loops.py b/examples/event_loops.py index 53d3ca2b..a76c5d91 100755 --- a/examples/event_loops.py +++ b/examples/event_loops.py @@ -6,7 +6,7 @@ This is an example of how to use cmd2 in a way so that cmd2 doesn't own the inne This opens up the possibility of registering cmd2 input with event loops, like asyncio, without occupying the main loop. """ -import cmd2 +from cmd2 import cmd2 class Cmd2EventBased(cmd2.Cmd): diff --git a/examples/example.py b/examples/example.py index 35e2c49f..f07b9c74 100755 --- a/examples/example.py +++ b/examples/example.py @@ -14,7 +14,7 @@ the transcript. import random import argparse -from cmd2 import Cmd, with_argparser +from cmd2.cmd2 import Cmd, with_argparser class CmdLineApp(Cmd): diff --git a/examples/help_categories.py b/examples/help_categories.py index cfb5f253..dcfbd31f 100755 --- a/examples/help_categories.py +++ b/examples/help_categories.py @@ -4,9 +4,10 @@ A sample application for tagging categories on commands. """ -from cmd2 import Cmd, categorize, __version__, with_argparser, with_category import argparse +from cmd2.cmd2 import Cmd, categorize, __version__, with_argparser, with_category + class HelpCategories(Cmd): """ Example cmd2 application. """ diff --git a/examples/paged_output.py b/examples/paged_output.py index bb410af6..9396f04e 100755 --- a/examples/paged_output.py +++ b/examples/paged_output.py @@ -3,8 +3,8 @@ """A simple example demonstrating the using paged output via the ppaged() method. """ -import cmd2 -from cmd2 import with_argument_list +from cmd2 import cmd2 +from cmd2.cmd2 import with_argument_list class PagedOutput(cmd2.Cmd): diff --git a/examples/persistent_history.py b/examples/persistent_history.py index 61e26b9c..251dbd67 100755 --- a/examples/persistent_history.py +++ b/examples/persistent_history.py @@ -5,7 +5,7 @@ This will allow end users of your cmd2-based application to use the arrow keys and Ctrl+r in a manner which persists across invocations of your cmd2 application. This can make it much easier for them to use your application. """ -import cmd2 +from cmd2 import cmd2 class Cmd2PersistentHistory(cmd2.Cmd): diff --git a/examples/pirate.py b/examples/pirate.py index bcf9a368..f6f4c629 100755 --- a/examples/pirate.py +++ b/examples/pirate.py @@ -7,7 +7,7 @@ presented as part of her PyCon 2010 talk. It demonstrates many features of cmd2. """ import argparse -from cmd2 import Cmd, with_argparser +from cmd2.cmd2 import Cmd, with_argparser class Pirate(Cmd): diff --git a/examples/python_scripting.py b/examples/python_scripting.py index 7e2cf345..865cf052 100755 --- a/examples/python_scripting.py +++ b/examples/python_scripting.py @@ -17,7 +17,7 @@ This application and the "scripts/conditional.py" script serve as an example for import argparse import os -import cmd2 +from cmd2 import cmd2 class CmdLineApp(cmd2.Cmd): diff --git a/examples/remove_unused.py b/examples/remove_unused.py index 8a567123..dfe0a055 100755 --- a/examples/remove_unused.py +++ b/examples/remove_unused.py @@ -9,7 +9,7 @@ name, they just won't clutter the help menu. Commands can also be removed entirely by using Python's "del". """ -import cmd2 +from cmd2 import cmd2 class RemoveUnusedBuiltinCommands(cmd2.Cmd): diff --git a/examples/subcommands.py b/examples/subcommands.py index 75c0733e..9bf6c666 100755 --- a/examples/subcommands.py +++ b/examples/subcommands.py @@ -1,5 +1,6 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # coding=utf-8 +# PYTHON_ARGCOMPLETE_OK """A simple example demonstrating how to use Argparse to support subcommands. @@ -8,11 +9,52 @@ and provides separate contextual help. """ import argparse -import cmd2 -from cmd2 import with_argparser - sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] +# 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') + +# create the parser for the "bar" subcommand +parser_bar = base_subparsers.add_parser('bar', help='bar help') + +bar_subparsers = parser_bar.add_subparsers(title='layer3', help='help for 3rd layer of commands') +parser_bar.add_argument('z', help='string') + +bar_subparsers.add_parser('apple', help='apple help') +bar_subparsers.add_parser('artichoke', help='artichoke help') +bar_subparsers.add_parser('cranberries', help='cranberries help') + +# create the parser for the "sport" subcommand +parser_sport = base_subparsers.add_parser('sport', help='sport help') +sport_arg = parser_sport.add_argument('sport', help='Enter name of a sport') +setattr(sport_arg, 'arg_choices', sport_item_strs) + +# Handle bash completion if it's installed +try: + # only move forward if we can import CompletionFinder and AutoCompleter + from cmd2.argcomplete_bridge import CompletionFinder + from cmd2.argparse_completer import AutoCompleter + if __name__ == '__main__': + with open('out.txt', 'a') as f: + f.write('Here 1') + f.flush() + completer = CompletionFinder() + completer(base_parser, AutoCompleter(base_parser)) +except ImportError: + pass + + +# Intentionally below the bash completion code to reduce tab completion lag +from cmd2 import cmd2 +from cmd2.cmd2 import with_argparser + + class SubcommandsExample(cmd2.Cmd): """ Example cmd2 application where we a base command which has a couple subcommands @@ -35,33 +77,9 @@ class SubcommandsExample(cmd2.Cmd): """sport subcommand of base command""" self.poutput('Sport is {}'.format(args.sport)) - # 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') + # Set handler functions for the subcommands 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.set_defaults(func=base_bar) - - bar_subparsers = parser_bar.add_subparsers(title='layer3', help='help for 3rd layer of commands') - parser_bar.add_argument('z', help='string') - - bar_subparsers.add_parser('apple', help='apple help') - bar_subparsers.add_parser('artichoke', help='artichoke help') - bar_subparsers.add_parser('cranberries', help='cranberries help') - - # create the parser for the "sport" subcommand - parser_sport = base_subparsers.add_parser('sport', help='sport help') - sport_arg = parser_sport.add_argument('sport', help='Enter name of a sport') - setattr(sport_arg, 'arg_choices', sport_item_strs) - - # Set both a function and tab completer for the "sport" subcommand parser_sport.set_defaults(func=base_sport) @with_argparser(base_parser) diff --git a/examples/submenus.py b/examples/submenus.py index 44b17f33..27c8cb10 100755 --- a/examples/submenus.py +++ b/examples/submenus.py @@ -11,7 +11,7 @@ of the submenu. Nesting of the submenus is done with the cmd2.AddSubmenu() decor from __future__ import print_function import sys -import cmd2 +from cmd2 import cmd2 from IPython import embed diff --git a/examples/tab_autocompletion.py b/examples/tab_autocompletion.py index a1a8daee..f3302533 100755 --- a/examples/tab_autocompletion.py +++ b/examples/tab_autocompletion.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # coding=utf-8 """ A example usage of the AutoCompleter @@ -10,14 +10,15 @@ import argparse import itertools from typing import List -import cmd2 -from cmd2 import with_argparser, with_category, argparse_completer +from cmd2 import cmd2, argparse_completer +from cmd2.cmd2 import with_argparser, with_category 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'] + def query_actors() -> List[str]: """Simulating a function that queries and returns a completion values""" return actors @@ -324,6 +325,7 @@ class TabCompleteExample(cmd2.Cmd): # 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 diff --git a/examples/tab_completion.py b/examples/tab_completion.py index 919e9560..30fa283d 100755 --- a/examples/tab_completion.py +++ b/examples/tab_completion.py @@ -4,8 +4,8 @@ """ import argparse -import cmd2 -from cmd2 import with_argparser, with_argument_list +from cmd2 import cmd2 +from cmd2.cmd2 import with_argparser, with_argument_list # List of strings used with flag and index based completion functions food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato'] diff --git a/examples/table_display.py b/examples/table_display.py index 2e6ea804..5d168408 100755 --- a/examples/table_display.py +++ b/examples/table_display.py @@ -12,7 +12,7 @@ WARNING: This example requires the tabulate module. """ import functools -import cmd2 +from cmd2 import cmd2 import tabulate # Format to use with tabulate module when displaying tables diff --git a/tests/conftest.py b/tests/conftest.py index ed76cba9..562ca4fa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ import sys from pytest import fixture from unittest import mock -import cmd2 +from cmd2 import cmd2 # Prefer statically linked gnureadline if available (for macOS compatibility due to issues with libedit) try: @@ -89,7 +89,7 @@ debug: False echo: False editor: vim feedback_to_output: False -locals_in_py: True +locals_in_py: False prompt: (Cmd) quiet: False timing: False @@ -106,7 +106,7 @@ debug: False # Show full error stack on error echo: False # Echo command issued into output editor: vim # Program used by ``edit`` feedback_to_output: False # Include nonessentials in `|`, `>` results -locals_in_py: True # Allow access to your application in py via self +locals_in_py: False # Allow access to your application in py via self prompt: (Cmd) # The prompt issued to solicit input quiet: False # Don't print nonessential feedback timing: False # Report execution times diff --git a/tests/pyscript/bar1.py b/tests/pyscript/bar1.py new file mode 100644 index 00000000..c6276a87 --- /dev/null +++ b/tests/pyscript/bar1.py @@ -0,0 +1 @@ +app.bar('11', '22') diff --git a/tests/pyscript/foo1.py b/tests/pyscript/foo1.py new file mode 100644 index 00000000..6e345d95 --- /dev/null +++ b/tests/pyscript/foo1.py @@ -0,0 +1 @@ +app.foo('aaa', 'bbb', counter=3, trueval=True, constval=True) diff --git a/tests/pyscript/foo2.py b/tests/pyscript/foo2.py new file mode 100644 index 00000000..d4df7616 --- /dev/null +++ b/tests/pyscript/foo2.py @@ -0,0 +1 @@ +app.foo('11', '22', '33', '44', counter=3, trueval=True, constval=True) diff --git a/tests/pyscript/foo3.py b/tests/pyscript/foo3.py new file mode 100644 index 00000000..db69edaf --- /dev/null +++ b/tests/pyscript/foo3.py @@ -0,0 +1 @@ +app.foo('11', '22', '33', '44', '55', '66', counter=3, trueval=False, constval=False) diff --git a/tests/pyscript/foo4.py b/tests/pyscript/foo4.py new file mode 100644 index 00000000..88fd3ce8 --- /dev/null +++ b/tests/pyscript/foo4.py @@ -0,0 +1,8 @@ +result = app.foo('aaa', 'bbb', counter=3) +out_text = 'Fail' +if result: + data = result.data + if 'aaa' in data.variable and 'bbb' in data.variable and data.counter == 3: + out_text = 'Success' + +print(out_text) diff --git a/tests/pyscript/help.py b/tests/pyscript/help.py new file mode 100644 index 00000000..3f67793c --- /dev/null +++ b/tests/pyscript/help.py @@ -0,0 +1 @@ +app.help()
\ No newline at end of file diff --git a/tests/pyscript/help_media.py b/tests/pyscript/help_media.py new file mode 100644 index 00000000..78025bdd --- /dev/null +++ b/tests/pyscript/help_media.py @@ -0,0 +1 @@ +app.help('media') diff --git a/tests/pyscript/media_movies_add1.py b/tests/pyscript/media_movies_add1.py new file mode 100644 index 00000000..a9139cb1 --- /dev/null +++ b/tests/pyscript/media_movies_add1.py @@ -0,0 +1 @@ +app.media.movies.add('My Movie', 'PG-13', director=('George Lucas', 'J. J. Abrams')) diff --git a/tests/pyscript/media_movies_add2.py b/tests/pyscript/media_movies_add2.py new file mode 100644 index 00000000..5c4617ae --- /dev/null +++ b/tests/pyscript/media_movies_add2.py @@ -0,0 +1 @@ +app.media.movies.add('My Movie', 'PG-13', actor=('Mark Hamill'), director=('George Lucas', 'J. J. Abrams')) diff --git a/tests/pyscript/media_movies_list1.py b/tests/pyscript/media_movies_list1.py new file mode 100644 index 00000000..0124bbcb --- /dev/null +++ b/tests/pyscript/media_movies_list1.py @@ -0,0 +1 @@ +app.media.movies.list()
\ No newline at end of file diff --git a/tests/pyscript/media_movies_list2.py b/tests/pyscript/media_movies_list2.py new file mode 100644 index 00000000..83f6c8ff --- /dev/null +++ b/tests/pyscript/media_movies_list2.py @@ -0,0 +1 @@ +app.media().movies().list()
\ No newline at end of file diff --git a/tests/pyscript/media_movies_list3.py b/tests/pyscript/media_movies_list3.py new file mode 100644 index 00000000..4fcf1288 --- /dev/null +++ b/tests/pyscript/media_movies_list3.py @@ -0,0 +1 @@ +app('media movies list')
\ No newline at end of file diff --git a/tests/pyscript/media_movies_list4.py b/tests/pyscript/media_movies_list4.py new file mode 100644 index 00000000..1165b0c5 --- /dev/null +++ b/tests/pyscript/media_movies_list4.py @@ -0,0 +1 @@ +app.media.movies.list(actor='Mark Hamill') diff --git a/tests/pyscript/media_movies_list5.py b/tests/pyscript/media_movies_list5.py new file mode 100644 index 00000000..962b1516 --- /dev/null +++ b/tests/pyscript/media_movies_list5.py @@ -0,0 +1 @@ +app.media.movies.list(actor=('Mark Hamill', 'Carrie Fisher')) diff --git a/tests/pyscript/media_movies_list6.py b/tests/pyscript/media_movies_list6.py new file mode 100644 index 00000000..5f8d3654 --- /dev/null +++ b/tests/pyscript/media_movies_list6.py @@ -0,0 +1 @@ +app.media.movies.list(rating='PG') diff --git a/tests/pyscript/media_movies_list7.py b/tests/pyscript/media_movies_list7.py new file mode 100644 index 00000000..bb0e28bb --- /dev/null +++ b/tests/pyscript/media_movies_list7.py @@ -0,0 +1 @@ +app.media.movies.list(rating=('PG', 'PG-13')) diff --git a/tests/scripts/recursive.py b/tests/scripts/recursive.py index 84f445bb..32c981b6 100644 --- a/tests/scripts/recursive.py +++ b/tests/scripts/recursive.py @@ -3,4 +3,4 @@ """ Example demonstrating that running a Python script recursively inside another Python script isn't allowed """ -cmd('pyscript ../script.py') +app('pyscript ../script.py') diff --git a/tests/test_argparse.py b/tests/test_argparse.py index f7c6eaba..94a7b5ed 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -5,7 +5,7 @@ Cmd2 testing for argument parsing import argparse import pytest -import cmd2 +from cmd2 import cmd2 from unittest import mock from .conftest import run_cmd, StdOut diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 17760d4d..b570ad3c 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -21,7 +21,7 @@ try: except ImportError: from unittest import mock -import cmd2 +from cmd2 import cmd2 from .conftest import run_cmd, normalize, BASE_HELP, BASE_HELP_VERBOSE, \ HELP_HISTORY, SHORTCUTS_TXT, SHOW_TXT, SHOW_LONG, StdOut @@ -109,7 +109,7 @@ def test_base_show_readonly(base_app): def test_cast(): - cast = cmd2.cmd2.cast + cast = cmd2.cast # Boolean assert cast(True, True) == True @@ -132,7 +132,7 @@ def test_cast(): assert cast([1,2], [3,4]) == [3,4] def test_cast_problems(capsys): - cast = cmd2.cmd2.cast + cast = cmd2.cast expected = 'Problem setting parameter (now {}) to {}; incorrect type?\n' @@ -256,8 +256,8 @@ def test_base_error(base_app): @pytest.fixture def hist(): - from cmd2.cmd2 import HistoryItem - h = cmd2.cmd2.History([HistoryItem('first'), HistoryItem('second'), HistoryItem('third'), HistoryItem('fourth')]) + from cmd2.cmd2 import History, HistoryItem + h = History([HistoryItem('first'), HistoryItem('second'), HistoryItem('third'), HistoryItem('fourth')]) return h def test_history_span(hist): @@ -700,18 +700,18 @@ def test_pipe_to_shell_error(base_app, capsys): assert err.startswith("EXCEPTION of type '{}' occurred with message:".format(expected_error)) -@pytest.mark.skipif(not cmd2.cmd2.can_clip, +@pytest.mark.skipif(not cmd2.can_clip, reason="Pyperclip could not find a copy/paste mechanism for your system") def test_send_to_paste_buffer(base_app): # Test writing to the PasteBuffer/Clipboard run_cmd(base_app, 'help >') expected = normalize(BASE_HELP) - assert normalize(cmd2.cmd2.get_paste_buffer()) == expected + assert normalize(cmd2.get_paste_buffer()) == expected # Test appending to the PasteBuffer/Clipboard run_cmd(base_app, 'help history >>') expected = normalize(BASE_HELP + '\n' + HELP_HISTORY) - assert normalize(cmd2.cmd2.get_paste_buffer()) == expected + assert normalize(cmd2.get_paste_buffer()) == expected def test_base_timing(base_app, capsys): @@ -1368,7 +1368,7 @@ optional arguments: reason="cmd2._which function only used on Mac and Linux") def test_which_editor_good(): editor = 'vi' - path = cmd2.cmd2._which(editor) + path = cmd2._which(editor) # Assert that the vi editor was found because it should exist on all Mac and Linux systems assert path @@ -1376,7 +1376,7 @@ def test_which_editor_good(): reason="cmd2._which function only used on Mac and Linux") def test_which_editor_bad(): editor = 'notepad.exe' - path = cmd2.cmd2._which(editor) + path = cmd2._which(editor) # Assert that the editor wasn't found because no notepad.exe on non-Windows systems ;-) assert path is None @@ -1403,7 +1403,7 @@ def multiline_app(): return app def test_multiline_complete_empty_statement_raises_exception(multiline_app): - with pytest.raises(cmd2.cmd2.EmptyStatement): + with pytest.raises(cmd2.EmptyStatement): multiline_app._complete_statement('') def test_multiline_complete_statement_without_terminator(multiline_app): @@ -1421,7 +1421,7 @@ def test_multiline_complete_statement_without_terminator(multiline_app): def test_clipboard_failure(capsys): # Force cmd2 clipboard to be disabled - cmd2.cmd2.disable_clip() + cmd2.disable_clip() app = cmd2.Cmd() # Redirect command output to the clipboard when a clipboard isn't present diff --git a/tests/test_completion.py b/tests/test_completion.py index 46e68764..a027d780 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -12,7 +12,7 @@ import argparse import os import sys -import cmd2 +from cmd2 import cmd2 import pytest from .conftest import complete_tester, StdOut from examples.subcommands import SubcommandsExample diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 1e745bda..ab8ed098 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -7,7 +7,7 @@ Released under MIT license, see LICENSE file """ import pytest -import cmd2 +from cmd2 import cmd2 from cmd2.parsing import StatementParser @@ -260,10 +260,10 @@ def test_parse_redirect_to_unicode_filename(parser): def test_empty_statement_raises_exception(): app = cmd2.Cmd() - with pytest.raises(cmd2.cmd2.EmptyStatement): + with pytest.raises(cmd2.EmptyStatement): app._complete_statement('') - with pytest.raises(cmd2.cmd2.EmptyStatement): + with pytest.raises(cmd2.EmptyStatement): app._complete_statement(' ') @pytest.mark.parametrize('line,command,args', [ diff --git a/tests/test_pyscript.py b/tests/test_pyscript.py new file mode 100644 index 00000000..8d0cefd8 --- /dev/null +++ b/tests/test_pyscript.py @@ -0,0 +1,214 @@ +""" +Unit/functional testing for argparse completer in cmd2 + +Copyright 2018 Eric Lin <anselor@gmail.com> +Released under MIT license, see LICENSE file +""" +import os +import pytest +from cmd2.cmd2 import Cmd, with_argparser +from cmd2 import argparse_completer +from .conftest import run_cmd, StdOut +from cmd2.utils import namedtuple_with_defaults + +class PyscriptExample(Cmd): + ratings_types = ['G', 'PG', 'PG-13', 'R', 'NC-17'] + + def _do_media_movies(self, args) -> None: + if not args.command: + self.do_help('media movies') + else: + print('media movies ' + str(args.__dict__)) + + def _do_media_shows(self, args) -> None: + if not args.command: + self.do_help('media shows') + + if not args.command: + self.do_help('media shows') + else: + print('media shows ' + str(args.__dict__)) + + media_parser = argparse_completer.ACArgumentParser(prog='media') + + media_types_subparsers = media_parser.add_subparsers(title='Media Types', dest='type') + + movies_parser = media_types_subparsers.add_parser('movies') + movies_parser.set_defaults(func=_do_media_movies) + + movies_commands_subparsers = movies_parser.add_subparsers(title='Commands', dest='command') + + movies_list_parser = movies_commands_subparsers.add_parser('list') + + movies_list_parser.add_argument('-t', '--title', help='Title Filter') + movies_list_parser.add_argument('-r', '--rating', help='Rating Filter', nargs='+', + choices=ratings_types) + movies_list_parser.add_argument('-d', '--director', help='Director Filter') + movies_list_parser.add_argument('-a', '--actor', help='Actor Filter', action='append') + + movies_add_parser = movies_commands_subparsers.add_parser('add') + movies_add_parser.add_argument('title', help='Movie Title') + movies_add_parser.add_argument('rating', help='Movie Rating', choices=ratings_types) + movies_add_parser.add_argument('-d', '--director', help='Director', nargs=(1, 2), required=True) + movies_add_parser.add_argument('actor', help='Actors', nargs='*') + + movies_delete_parser = movies_commands_subparsers.add_parser('delete') + + shows_parser = media_types_subparsers.add_parser('shows') + shows_parser.set_defaults(func=_do_media_shows) + + shows_commands_subparsers = shows_parser.add_subparsers(title='Commands', dest='command') + + shows_list_parser = shows_commands_subparsers.add_parser('list') + + @with_argparser(media_parser) + def do_media(self, args): + """Media management command demonstrates multiple layers of subcommands being handled by AutoCompleter""" + func = getattr(args, 'func', None) + if func is not None: + # Call whatever subcommand function was selected + func(self, args) + else: + # No subcommand was provided, so call help + self.do_help('media') + + foo_parser = argparse_completer.ACArgumentParser(prog='foo') + foo_parser.add_argument('-c', dest='counter', action='count') + foo_parser.add_argument('-t', dest='trueval', action='store_true') + foo_parser.add_argument('-n', dest='constval', action='store_const', const=42) + foo_parser.add_argument('variable', nargs=(2, 3)) + foo_parser.add_argument('optional', nargs='?') + foo_parser.add_argument('zeroormore', nargs='*') + + @with_argparser(foo_parser) + def do_foo(self, args): + print('foo ' + str(args.__dict__)) + if self._in_py: + FooResult = namedtuple_with_defaults('FooResult', + ['counter', 'trueval', 'constval', + 'variable', 'optional', 'zeroormore']) + self._last_result = FooResult(**{'counter': args.counter, + 'trueval': args.trueval, + 'constval': args.constval, + 'variable': args.variable, + 'optional': args.optional, + 'zeroormore': args.zeroormore}) + + bar_parser = argparse_completer.ACArgumentParser(prog='bar') + bar_parser.add_argument('first') + bar_parser.add_argument('oneormore', nargs='+') + bar_parser.add_argument('-a', dest='aaa') + + @with_argparser(bar_parser) + def do_bar(self, args): + print('bar ' + str(args.__dict__)) + + +@pytest.fixture +def ps_app(): + c = PyscriptExample() + c.stdout = StdOut() + + return c + + +class PyscriptCustomNameExample(Cmd): + def __init__(self): + super().__init__() + self.pyscript_name = 'custom' + + def do_echo(self, out): + print(out) + + +@pytest.fixture +def ps_echo(): + c = PyscriptCustomNameExample() + c.stdout = StdOut() + + return c + + +@pytest.mark.parametrize('command, pyscript_file', [ + ('help', 'help.py'), + ('help media', 'help_media.py'), +]) +def test_pyscript_help(ps_app, capsys, request, command, pyscript_file): + test_dir = os.path.dirname(request.module.__file__) + python_script = os.path.join(test_dir, 'pyscript', pyscript_file) + expected = run_cmd(ps_app, command) + + assert len(expected) > 0 + assert len(expected[0]) > 0 + out = run_cmd(ps_app, 'pyscript {}'.format(python_script)) + assert len(out) > 0 + assert out == expected + + +@pytest.mark.parametrize('command, pyscript_file', [ + ('media movies list', 'media_movies_list1.py'), + ('media movies list', 'media_movies_list2.py'), + ('media movies list', 'media_movies_list3.py'), + ('media movies list -a "Mark Hamill"', 'media_movies_list4.py'), + ('media movies list -a "Mark Hamill" -a "Carrie Fisher"', 'media_movies_list5.py'), + ('media movies list -r PG', 'media_movies_list6.py'), + ('media movies list -r PG PG-13', 'media_movies_list7.py'), + ('media movies add "My Movie" PG-13 --director "George Lucas" "J. J. Abrams"', + 'media_movies_add1.py'), + ('media movies add "My Movie" PG-13 --director "George Lucas" "J. J. Abrams" "Mark Hamill"', + 'media_movies_add2.py'), + ('foo aaa bbb -ccc -t -n', 'foo1.py'), + ('foo 11 22 33 44 -ccc -t -n', 'foo2.py'), + ('foo 11 22 33 44 55 66 -ccc', 'foo3.py'), + ('bar 11 22', 'bar1.py') +]) +def test_pyscript_out(ps_app, capsys, request, command, pyscript_file): + test_dir = os.path.dirname(request.module.__file__) + python_script = os.path.join(test_dir, 'pyscript', pyscript_file) + run_cmd(ps_app, command) + expected, _ = capsys.readouterr() + + assert len(expected) > 0 + run_cmd(ps_app, 'pyscript {}'.format(python_script)) + out, _ = capsys.readouterr() + assert len(out) > 0 + assert out == expected + + +@pytest.mark.parametrize('command, error', [ + ('app.noncommand', 'AttributeError'), + ('app.media.noncommand', 'AttributeError'), + ('app.media.movies.list(artist="Invalid Keyword")', 'TypeError'), + ('app.foo(counter="a")', 'TypeError'), + ('app.foo("aaa")', 'ValueError'), +]) +def test_pyscript_errors(ps_app, capsys, command, error): + run_cmd(ps_app, 'py {}'.format(command)) + _, err = capsys.readouterr() + + assert len(err) > 0 + assert 'Traceback' in err + assert error in err + + +@pytest.mark.parametrize('pyscript_file, exp_out', [ + ('foo4.py', 'Success'), +]) +def test_pyscript_results(ps_app, capsys, request, pyscript_file, exp_out): + test_dir = os.path.dirname(request.module.__file__) + python_script = os.path.join(test_dir, 'pyscript', pyscript_file) + + run_cmd(ps_app, 'pyscript {}'.format(python_script)) + expected, _ = capsys.readouterr() + assert len(expected) > 0 + assert exp_out in expected + + +def test_pyscript_custom_name(ps_echo, capsys): + message = 'blah!' + run_cmd(ps_echo, 'py custom.echo("{}")'.format(message)) + expected, _ = capsys.readouterr() + assert len(expected) > 0 + expected = expected.splitlines() + assert message == expected[0] + diff --git a/tests/test_submenu.py b/tests/test_submenu.py index fbb9857b..db334daa 100644 --- a/tests/test_submenu.py +++ b/tests/test_submenu.py @@ -4,7 +4,7 @@ Cmd2 testing for argument parsing """ import pytest -import cmd2 +from cmd2 import cmd2 from .conftest import run_cmd, StdOut, normalize diff --git a/tests/test_transcript.py b/tests/test_transcript.py index 6330ab09..c0fb49c1 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -14,7 +14,7 @@ import random from unittest import mock import pytest -import cmd2 +from cmd2 import cmd2 from .conftest import run_cmd, StdOut, normalize class CmdLineApp(cmd2.Cmd): @@ -184,7 +184,7 @@ now: ---> assert out == expected -class TestMyAppCase(cmd2.cmd2.Cmd2TestCase): +class TestMyAppCase(cmd2.Cmd2TestCase): CmdApp = CmdLineApp CmdApp.testfiles = ['tests/transcript.txt'] @@ -288,7 +288,7 @@ def test_transcript(request, capsys, filename, feedback_to_output): def test_parse_transcript_expected(expected, transformed): app = CmdLineApp() - class TestMyAppCase(cmd2.cmd2.Cmd2TestCase): + class TestMyAppCase(cmd2.Cmd2TestCase): cmdapp = app testcase = TestMyAppCase() diff --git a/tests/transcripts/regex_set.txt b/tests/transcripts/regex_set.txt index bf88c294..b818c464 100644 --- a/tests/transcripts/regex_set.txt +++ b/tests/transcripts/regex_set.txt @@ -10,7 +10,7 @@ debug: False echo: False editor: /.*/ feedback_to_output: False -locals_in_py: True +locals_in_py: False maxrepeats: 3 prompt: (Cmd)/ / quiet: False |