diff options
-rw-r--r-- | cmd2/argcomplete_bridge.py | 15 | ||||
-rwxr-xr-x | cmd2/argparse_completer.py | 4 | ||||
-rwxr-xr-x | examples/subcommands.py | 3 | ||||
-rw-r--r-- | tests/test_bashcompletion.py | 232 | ||||
-rw-r--r-- | tox.ini | 8 |
5 files changed, 250 insertions, 12 deletions
diff --git a/cmd2/argcomplete_bridge.py b/cmd2/argcomplete_bridge.py index 583f3345..a036af1e 100644 --- a/cmd2/argcomplete_bridge.py +++ b/cmd2/argcomplete_bridge.py @@ -4,7 +4,7 @@ try: # check if argcomplete is installed import argcomplete -except ImportError: +except ImportError: # pragma: no cover # not installed, skip the rest of the file pass @@ -70,7 +70,7 @@ else: break except ValueError: # ValueError can be caused by missing closing quote - if not quotes_to_try: + if not quotes_to_try: # pragma: no cover # Since we have no more quotes to try, something else # is causing the parsing error. Return None since # this means the line is malformed. @@ -228,15 +228,14 @@ else: 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)) + comp_type = int(os.environ["COMP_TYPE"]) + if comp_type == 63: # type is 63 for second tab press + print(outstr.rstrip(), file=argcomplete.debug_stream, end='') + + output_stream.write(ifs.join([ifs, ' ']).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 diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 4964b1ec..a8a0f24a 100755 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -877,7 +877,9 @@ class ACArgumentParser(argparse.ArgumentParser): return super(ACArgumentParser, self)._match_argument(action, arg_strings_pattern) - def _parse_known_args(self, arg_strings, namespace): + # This is the official python implementation with a 5 year old patch applied + # See the comment below describing the patch + def _parse_known_args(self, arg_strings, namespace): # pragma: no cover # replace arg strings that are file references if self.fromfile_prefix_chars is not None: arg_strings = self._read_args_from_files(arg_strings) diff --git a/examples/subcommands.py b/examples/subcommands.py index 9bf6c666..3dd2c683 100755 --- a/examples/subcommands.py +++ b/examples/subcommands.py @@ -41,9 +41,6 @@ try: 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: diff --git a/tests/test_bashcompletion.py b/tests/test_bashcompletion.py new file mode 100644 index 00000000..ceae2aa9 --- /dev/null +++ b/tests/test_bashcompletion.py @@ -0,0 +1,232 @@ +# coding=utf-8 +""" +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 +import sys +from typing import List + +from cmd2.argparse_completer import ACArgumentParser, AutoCompleter + + +try: + from cmd2.argcomplete_bridge import CompletionFinder + skip_reason1 = False + skip_reason = '' +except ImportError: + # Don't test if argcomplete isn't present (likely on Windows) + skip_reason1 = True + skip_reason = "argcomplete isn't installed\n" + +skip_reason2 = "TRAVIS" in os.environ and os.environ["TRAVIS"] == "true" +if skip_reason2: + skip_reason += 'These tests cannot run on TRAVIS\n' + +skip_reason3 = sys.platform.startswith('win') +if skip_reason3: + skip_reason = 'argcomplete doesn\'t support Windows' + +skip = skip_reason1 or skip_reason2 or skip_reason3 + +skip_mac = sys.platform.startswith('dar') + + +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 + + +@pytest.fixture +def parser1(): + """creates a argparse object to test completion against""" + 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 = 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_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_commands_subparsers.add_parser('list') + + return media_parser + + +# noinspection PyShadowingNames +@pytest.mark.skipif(skip, reason=skip_reason) +def test_bash_nocomplete(parser1): + completer = CompletionFinder() + result = completer(parser1, AutoCompleter(parser1)) + assert result is None + + +# save the real os.fdopen +os_fdopen = os.fdopen + + +def my_fdopen(fd, mode, *args): + """mock fdopen that redirects 8 and 9 from argcomplete to stdin/stdout for testing""" + if fd > 7: + return os_fdopen(fd - 7, mode, *args) + return os_fdopen(fd, mode) + + +# noinspection PyShadowingNames +@pytest.mark.skipif(skip, reason=skip_reason) +def test_invalid_ifs(parser1, mock): + completer = CompletionFinder() + + mock.patch.dict(os.environ, {'_ARGCOMPLETE': '1', + '_ARGCOMPLETE_IFS': '\013\013'}) + + mock.patch.object(os, 'fdopen', my_fdopen) + + with pytest.raises(SystemExit): + completer(parser1, AutoCompleter(parser1), exit_method=sys.exit) + + +# noinspection PyShadowingNames +@pytest.mark.skipif(skip or skip_mac, reason=skip_reason) +@pytest.mark.parametrize('comp_line, exp_out, exp_err', [ + ('media ', 'movies\013shows', ''), + ('media mo', 'movies', ''), + ('media movies add ', '\013\013 ', ''' +Hint: + TITLE Movie Title'''), + ('media movies list -a "J', '"John Boyega"\013"Jake Lloyd"', ''), + ('media movies list ', '', '') +]) +def test_commands(parser1, capfd, mock, comp_line, exp_out, exp_err): + completer = CompletionFinder() + + mock.patch.dict(os.environ, {'_ARGCOMPLETE': '1', + '_ARGCOMPLETE_IFS': '\013', + 'COMP_TYPE': '63', + 'COMP_LINE': comp_line, + 'COMP_POINT': str(len(comp_line))}) + + mock.patch.object(os, 'fdopen', my_fdopen) + + with pytest.raises(SystemExit): + choices = {'actor': query_actors, # function + } + autocompleter = AutoCompleter(parser1, arg_choices=choices) + completer(parser1, autocompleter, exit_method=sys.exit) + + out, err = capfd.readouterr() + assert out == exp_out + assert err == exp_err + + +def fdopen_fail_8(fd, mode, *args): + """mock fdopen that forces failure if fd == 8""" + if fd == 8: + raise IOError() + return my_fdopen(fd, mode, *args) + + +# noinspection PyShadowingNames +@pytest.mark.skipif(skip, reason=skip_reason) +def test_fail_alt_stdout(parser1, mock): + completer = CompletionFinder() + + comp_line = 'media movies list ' + mock.patch.dict(os.environ, {'_ARGCOMPLETE': '1', + '_ARGCOMPLETE_IFS': '\013', + 'COMP_TYPE': '63', + 'COMP_LINE': comp_line, + 'COMP_POINT': str(len(comp_line))}) + mock.patch.object(os, 'fdopen', fdopen_fail_8) + + try: + choices = {'actor': query_actors, # function + } + autocompleter = AutoCompleter(parser1, arg_choices=choices) + completer(parser1, autocompleter, exit_method=sys.exit) + except SystemExit as err: + assert err.code == 1 + + +def fdopen_fail_9(fd, mode, *args): + """mock fdopen that forces failure if fd == 9""" + if fd == 9: + raise IOError() + return my_fdopen(fd, mode, *args) + + +# noinspection PyShadowingNames +@pytest.mark.skipif(skip or skip_mac, reason=skip_reason) +def test_fail_alt_stderr(parser1, capfd, mock): + completer = CompletionFinder() + + comp_line = 'media movies add ' + exp_out = '\013\013 ' + exp_err = ''' +Hint: + TITLE Movie Title''' + + mock.patch.dict(os.environ, {'_ARGCOMPLETE': '1', + '_ARGCOMPLETE_IFS': '\013', + 'COMP_TYPE': '63', + 'COMP_LINE': comp_line, + 'COMP_POINT': str(len(comp_line))}) + mock.patch.object(os, 'fdopen', fdopen_fail_9) + + with pytest.raises(SystemExit): + choices = {'actor': query_actors, # function + } + autocompleter = AutoCompleter(parser1, arg_choices=choices) + completer(parser1, autocompleter, exit_method=sys.exit) + + out, err = capfd.readouterr() + assert out == exp_out + assert err == exp_err @@ -15,6 +15,8 @@ deps = pyperclip pytest pytest-cov + pytest-mock + argcomplete wcwidth commands = py.test {posargs} --cov @@ -25,6 +27,8 @@ deps = mock pyperclip pytest + pytest-mock + argcomplete wcwidth commands = py.test -v @@ -42,6 +46,8 @@ deps = pyperclip pytest pytest-cov + pytest-mock + argcomplete wcwidth commands = py.test {posargs} --cov @@ -62,6 +68,8 @@ commands = deps = pyperclip pytest + pytest-mock + argcomplete wcwidth commands = py.test -v |