diff options
-rw-r--r-- | cmd2/argcomplete_bridge.py | 459 | ||||
-rwxr-xr-x | examples/subcommands.py | 29 |
2 files changed, 251 insertions, 237 deletions
diff --git a/cmd2/argcomplete_bridge.py b/cmd2/argcomplete_bridge.py index f1ed910e..46950cc6 100644 --- a/cmd2/argcomplete_bridge.py +++ b/cmd2/argcomplete_bridge.py @@ -1,238 +1,247 @@ # coding=utf-8 """Hijack the ArgComplete's bash completion handler to return AutoCompleter results""" -import argcomplete -from contextlib import redirect_stdout -import copy -from io import StringIO -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()): +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 strip_quotes, QUOTES + + + def tokens_for_completion(line, endidx): """ - :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. + 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 """ - 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) + unclosed_quote = '' + quotes_to_try = copy.copy(QUOTES) - if "_ARGCOMPLETE" not in os.environ: - # not an argument completion invocation - return + tmp_line = line[:endidx] + tmp_endidx = endidx - global debug_stream - try: - debug_stream = os.fdopen(9, "w") - except: - debug_stream = sys.stderr + # 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) - if output_stream is None: + # 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: - output_stream = os.fdopen(8, "wb") + debug_stream = os.fdopen(9, "w") except: - argcomplete.debug("Unable to open fd 8 for writing, quitting") + 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) - # 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)) - 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() - debug_stream.flush() - exit_method(0) + 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)) + 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() + debug_stream.flush() + exit_method(0) diff --git a/examples/subcommands.py b/examples/subcommands.py index 01f5b218..9bf6c666 100755 --- a/examples/subcommands.py +++ b/examples/subcommands.py @@ -7,8 +7,6 @@ This example shows an easy way for a single command to have many subcommands, each of which takes different arguments and provides separate contextual help. """ -from cmd2.argcomplete_bridge import CompletionFinder -from cmd2.argparse_completer import AutoCompleter import argparse sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] @@ -37,16 +35,22 @@ 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 - -if __name__ == '__main__': - with open('out.txt', 'a') as f: - f.write('Here 1') - f.flush() - completer = CompletionFinder() - completer(base_parser, AutoCompleter(base_parser)) - - +# 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 @@ -73,6 +77,7 @@ class SubcommandsExample(cmd2.Cmd): """sport subcommand of base command""" self.poutput('Sport is {}'.format(args.sport)) + # Set handler functions for the subcommands parser_foo.set_defaults(func=base_foo) parser_bar.set_defaults(func=base_bar) parser_sport.set_defaults(func=base_sport) |