From 0a1c41ce7048b45fc7ef9b0176d988c26861224e Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 24 Apr 2018 16:17:25 -0400 Subject: Initial approach to the pyscript revamp. Doesn't handle all argparse argument options yet (nargs, append, flag, probably more) For #368 --- cmd2/argparse_completer.py | 8 +++- cmd2/cmd2.py | 7 ++- cmd2/pyscript_bridge.py | 107 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 cmd2/pyscript_bridge.py diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 03f2d965..8b5246e8 100755 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -75,6 +75,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 +104,7 @@ class _RangeAction(object): self.nargs_adjusted = nargs +# noinspection PyShadowingBuiltins,PyShadowingBuiltins class _StoreRangeAction(argparse._StoreAction, _RangeAction): def __init__(self, option_strings, @@ -131,6 +133,7 @@ class _StoreRangeAction(argparse._StoreAction, _RangeAction): metavar=metavar) +# noinspection PyShadowingBuiltins,PyShadowingBuiltins class _AppendRangeAction(argparse._AppendAction, _RangeAction): def __init__(self, option_strings, @@ -433,7 +436,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): @@ -571,6 +573,7 @@ class AutoCompleter(object): ############################################################################### +# noinspection PyCompatibility,PyShadowingBuiltins,PyShadowingBuiltins class ACHelpFormatter(argparse.HelpFormatter): """Custom help formatter to configure ordering of help text""" @@ -631,6 +634,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 +726,7 @@ class ACHelpFormatter(argparse.HelpFormatter): else: result = default_metavar + # noinspection PyMissingOrEmptyDocstring def format(tuple_size): if isinstance(result, tuple): return result @@ -748,6 +753,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.""" diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 8d8a5b07..4437426e 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -2877,13 +2877,13 @@ Usage: Usage: unalias [-a] name [name ...] Non-python commands can be issued with ``cmd("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 @@ -2906,8 +2906,11 @@ Usage: Usage: unalias [-a] name [name ...] """ return self.onecmd_plus_hooks(cmd_plus_args + '\n') + bridge = PyscriptBridge(self) + self.pystate['self'] = bridge self.pystate['run'] = run - self.pystate['cmd'] = onecmd_plus_hooks + self.pystate['cmd'] = bridge + self.pystate['app'] = bridge localvars = (self.locals_in_py and self.pystate) or {} interp = InteractiveConsole(locals=localvars) diff --git a/cmd2/pyscript_bridge.py b/cmd2/pyscript_bridge.py new file mode 100644 index 00000000..88e12bfb --- /dev/null +++ b/cmd2/pyscript_bridge.py @@ -0,0 +1,107 @@ +"""Bridges calls made inside of pyscript with the Cmd2 host app while maintaining a reasonable +degree of isolation between the two""" + +import argparse + +class ArgparseFunctor: + def __init__(self, cmd2_app, item, parser): + self._cmd2_app = cmd2_app + self._item = item + self._parser = parser + + self._args = {} + self.__current_subcommand_parser = parser + + def __getattr__(self, item): + # look for sub-command + 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 + return super().__getatttr__(item) + + def __call__(self, *args, **kwargs): + next_pos_index = 0 + + has_subcommand = False + consumed_kw = [] + for action in self.__current_subcommand_parser._actions: + # is this a flag option? + if action.option_strings: + if action.dest in kwargs: + self._args[action.dest] = kwargs[action.dest] + consumed_kw.append(action.dest) + else: + if not isinstance(action, argparse._SubParsersAction): + if next_pos_index < len(args): + self._args[action.dest] = args[next_pos_index] + next_pos_index += 1 + else: + has_subcommand = True + + for kw in kwargs: + if kw not in 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 traverse_parser(parser): + for action in parser._actions: + # was something provided for the argument + if action.dest in self._args: + # was the argument a flag? + # TODO: Handle 'narg' and 'append' options + if action.option_strings: + cmd_str[0] += '"{}" "{}" '.format(action.option_strings[0], self._args[action.dest]) + else: + cmd_str[0] += '"{}" '.format(self._args[action.dest]) + + if isinstance(action, argparse._SubParsersAction): + traverse_parser(action.choices[self._args[action.dest]]) + traverse_parser(self._parser) + + func(cmd_str[0]) + return self._cmd2_app._last_result + + +class PyscriptBridge(object): + def __init__(self, cmd2_app): + self._cmd2_app = cmd2_app + self._last_result = None + + def __getattr__(self, item: str): + commands = self._cmd2_app.get_all_commands() + if item in commands: + func = getattr(self._cmd2_app, 'do_' + item) + + try: + parser = getattr(func, 'argparser') + except AttributeError: + def wrap_func(args=''): + func(args) + return self._cmd2_app._last_result + return wrap_func + else: + return ArgparseFunctor(self._cmd2_app, item, parser) + + return super().__getattr__(item) + + def __call__(self, args): + self._cmd2_app.onecmd_plus_hooks(args + '\n') + self._last_result = self._cmd2_app._last_result + return self._cmd2_app._last_result -- cgit v1.2.1 From 4193ef0344359d050bbfa469cbb898a52db97622 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 24 Apr 2018 19:35:34 -0400 Subject: Initial customization of CompletionFinder --- cmd2/__init__.py | 17 ++++ cmd2/argcomplete_bridge.py | 206 +++++++++++++++++++++++++++++++++++++++++++++ cmd2/cmd2.py | 18 +--- 3 files changed, 224 insertions(+), 17 deletions(-) create mode 100644 cmd2/argcomplete_bridge.py diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 8e744e03..773eba37 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -3,3 +3,20 @@ # from .cmd2 import __version__, Cmd, set_posix_shlex, set_strip_quotes, AddSubmenu, CmdResult, categorize from .cmd2 import with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category + +# Used for tab completion and word breaks. Do not change. +QUOTES = ['"', "'"] +REDIRECTION_CHARS = ['|', '<', '>'] + + +def strip_quotes(arg: str) -> str: + """ Strip outer quotes from a string. + + Applies to both single and double quotes. + + :param arg: string to strip outer quotes from + :return: same string with potentially outer quotes stripped + """ + if len(arg) > 1 and arg[0] == arg[-1] and arg[0] in QUOTES: + arg = arg[1:-1] + return arg diff --git a/cmd2/argcomplete_bridge.py b/cmd2/argcomplete_bridge.py new file mode 100644 index 00000000..93b3cbbb --- /dev/null +++ b/cmd2/argcomplete_bridge.py @@ -0,0 +1,206 @@ +# coding=utf-8 +"""Hijack the ArgComplete's bash completion handler to return AutoCompleter results""" + +import argcomplete +import copy +import os +import shlex +import sys + +from . import strip_quotes, QUOTES + + +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(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 = [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 + + global debug_stream + try: + debug_stream = os.fdopen(9, "w") + except: + debug_stream = sys.stderr + + if output_stream is None: + try: + output_stream = os.fdopen(8, "wb") + except: + 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 + # 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: