Repository: pallets/click
Pull request #681 (2016-11-04 22:05:47)

Issue #535: Bash completion for click.Choice()
Added support for bash compeletion of
type=click.Choice Options and Arguments.

Added comprehensive tests for bash completion.
There are many scenarios present for arguments.

pgkelley4: If this got approved I'd also be happy to take a stab at cleaning up and unit testing: https://github.com/pallets/click/pull/428
pgkelley4: It looks like this doesn't work for the case of `--opt=`.

I'll get a fix up for this tomorrow.
pgkelley4: Fixed up the commit. This is ready for review.
zmt: What's the ETA of this getting accepted and released?
zmt: bump - ETA?
DropD: What happened to this? I would really like this to work!
socketpair: ```
#!/usr/bin/python3

import click

@click.group()
def ics():
    pass

@ics.command()
@click.argument('name', type=click.Choice(['on', 'off']))
def test(name):
    click.echo('Test: %s' % name)

if __name__ == '__main__':
    ics()
```

TAB-completion still does not work (click 6.6)
pgkelley4: @zmt @DropD @socketpair No one from pallets has looked at this yet.

@untitaker Could you or someone else take a look at this?
socketpair: To take a look, and ?
stopthatcow: @untitaker what is needed to move this forward? I have another PR that could build on this for dynamic autocompletion: https://github.com/pallets/click/pull/755
untitaker: Yeah LGTM. Please resolve conflicts and add a changelog entry.
stopthatcow: @pgkelley4 This is great work. I look forward to building on top of this.

Patch:
diff --git a/.gitignore b/.gitignore
index d140f6f..2035180 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,4 @@ click.egg-info
 .tox
 .cache
 .ropeproject
+.idea
diff --git a/CHANGES b/CHANGES
index b17a179..c24e42e 100644
--- a/CHANGES
+++ b/CHANGES
@@ -8,6 +8,8 @@ Version 7.0
 
 (upcoming release with new features, release date to be decided)
 
+- Added support for bash completion of type=click.Choice for Options and
+  Arguments. See #535.
 - The user is now presented with the available choices if prompt=True and
   type=click.Choice in a click.option. The choices are listed within
   parentthesis like 'Choose fruit (apple, orange): '.
diff --git a/click/_bashcomplete.py b/click/_bashcomplete.py
index d9d26d2..bd1c278 100644
--- a/click/_bashcomplete.py
+++ b/click/_bashcomplete.py
@@ -1,9 +1,15 @@
+import collections
+import copy
 import os
 import re
+
 from .utils import echo
 from .parser import split_arg_string
-from .core import MultiCommand, Option
+from .core import MultiCommand, Option, Argument
+from .types import Choice
+
 
+WORDBREAK = '='
 
 COMPLETION_SCRIPT = '''
 %(complete_func)s() {
@@ -29,6 +35,13 @@ def get_completion_script(prog_name, complete_var):
 
 
 def resolve_ctx(cli, prog_name, args):
+    """
+    Parse into a hierarchy of contexts. Contexts are connected through the parent variable.
+    :param cli: command definition
+    :param prog_name: the program that is running
+    :param args: full list of args
+    :return: the final context/command parsed
+    """
     ctx = cli.make_context(prog_name, args, resilient_parsing=True)
     while ctx.protected_args + ctx.args and isinstance(ctx.command, MultiCommand):
         a = ctx.protected_args + ctx.args
@@ -39,19 +52,103 @@ def resolve_ctx(cli, prog_name, args):
     return ctx
 
 
+def start_of_option(param_str):
+    """
+    :param param_str: param_str to check
+    :return: whether or not this is the start of an option declaration (i.e. starts "-" or "--")
+    """
+    return param_str and param_str[:1] == '-'
+
+
+def is_incomplete_option(all_args, cmd_param):
+    """
+    :param all_args: the full original list of args supplied
+    :param cmd_param: the current command paramter
+    :return: whether or not the last option declaration (i.e. starts "-" or "--") is incomplete and
+    corresponds to this cmd_param. In other words whether this cmd_param option can still accept
+    values
+    """
+    if cmd_param.is_flag:
+        return False
+    last_option = None
+    for index, arg_str in enumerate(reversed([arg for arg in all_args if arg != WORDBREAK])):
+        if index + 1 > cmd_param.nargs:
+            break
+        if start_of_option(arg_str):
+            last_option = arg_str
+
+    return True if last_option and last_option in cmd_param.opts else False
+
+
+def is_incomplete_argument(current_params, cmd_param):
+    """
+    :param current_params: the current params and values for this argument as already entered
+    :param cmd_param: the current command parameter
+    :return: whether or not the last argument is incomplete and corresponds to this cmd_param. In
+    other words whether or not the this cmd_param argument can still accept values
+    """
+    current_param_values = current_params[cmd_param.name]
+    if current_param_values is None:
+        return True
+    if cmd_param.nargs == -1:
+        return True
+    if isinstance(current_param_values, collections.Iterable) \
+            and cmd_param.nargs > 1 and len(current_param_values) < cmd_param.nargs:
+        return True
+    return False
+
+
 def get_choices(cli, prog_name, args, incomplete):
+    """
+    :param cli: command definition
+    :param prog_name: the program that is running
+    :param args: full list of args
+    :param incomplete: the incomplete text to autocomplete
+    :return: all the possible completions for the incomplete
+    """
+    all_args = copy.deepcopy(args)
+
     ctx = resolve_ctx(cli, prog_name, args)
     if ctx is None:
         return
 
+    # In newer versions of bash long opts with '='s are partitioned, but it's easier to parse
+    # without the '='
+    if start_of_option(incomplete) and WORDBREAK in incomplete:
+        partition_incomplete = incomplete.partition(WORDBREAK)
+        all_args.append(partition_incomplete[0])
+        incomplete = partition_incomplete[2]
+    elif incomplete == WORDBREAK:
+        incomplete = ''
+
     choices = []
-    if incomplete and not incomplete[:1].isalnum():
+    found_param = False
+    if start_of_option(incomplete):
+        # completions for options
         for param in ctx.command.params:
-            if not isinstance(param, Option):
-                continue
-            choices.extend(param.opts)
-            choices.extend(param.secondary_opts)
-    elif isinstance(ctx.command, MultiCommand):
+            if isinstance(param, Option):
+                choices.extend([param_opt for param_opt in param.opts + param.secondary_opts
+                                if param_opt not in all_args or param.multiple])
+        found_param = True
+    if not found_param:
+        # completion for option values by choices
+        for cmd_param in ctx.command.params:
+            if isinstance(cmd_param, Option) and is_incomplete_option(all_args, cmd_param):
+                if isinstance(cmd_param.type, Choice):
+                    choices.extend(cmd_param.type.choices)
+                found_param = True
+                break
+    if not found_param:
+        # completion for argument values by choices
+        for cmd_param in ctx.command.params:
+            if isinstance(cmd_param, Argument) and is_incomplete_argument(ctx.params, cmd_param):
+                if isinstance(cmd_param.type, Choice):
+                    choices.extend(cmd_param.type.choices)
+                found_param = True
+                break
+
+    if not found_param and isinstance(ctx.command, MultiCommand):
+        # completion for any subcommands
         choices.extend(ctx.command.list_commands(ctx))
 
     for item in choices:
diff --git a/docs/bashcomplete.rst b/docs/bashcomplete.rst
index 257bcff..9dd9a99 100644
--- a/docs/bashcomplete.rst
+++ b/docs/bashcomplete.rst
@@ -13,7 +13,8 @@ Limitations
 Bash completion is only available if a script has been installed properly,
 and not executed through the ``python`` command.  For information about
 how to do that, see :ref:`setuptools-integration`.  Also, Click currently
-only supports completion for Bash.
+only supports completion for Bash. Zsh support is available through Zsh's
+bash completion compatibility mode.
 
 Currently, Bash completion is an internal feature that is not customizable.
 This might be relaxed in future versions.
@@ -21,8 +22,9 @@ This might be relaxed in future versions.
 What it Completes
 -----------------
 
-Generally, the Bash completion support will complete subcommands and
-parameters.  Subcommands are always listed whereas parameters only if at
+Generally, the Bash completion support will complete subcommands, options
+and any option or argument values where the type is click.Choice.
+Subcommands and choices are always listed whereas options only if at
 least a dash has been provided.  Example::
 
     $ repo <TAB><TAB>
@@ -67,3 +69,14 @@ This can be easily accomplished::
 And then you would put this into your bashrc instead::
 
     . /path/to/foo-bar-complete.sh
+
+Zsh Compatibility
+----------------
+
+To enable Bash completion in Zsh, add the following lines to your .zshrc:
+
+    autoload bashcompinit
+    bashcompinit
+
+See https://github.com/pallets/click/issues/323 for more information on
+this issue.


Test patch:
diff --git a/tests/test_bashcomplete.py b/tests/test_bashcomplete.py
index fea096c..42d2255 100644
--- a/tests/test_bashcomplete.py
+++ b/tests/test_bashcomplete.py
@@ -14,6 +14,42 @@ def test_single_command():
     assert list(get_choices(cli, 'lol', [], '')) == []
 
 
+def test_boolean_flag():
+    @click.command()
+    @click.option('--shout/--no-shout', default=False)
+    def cli(local_opt):
+        pass
+
+    assert list(get_choices(cli, 'lol', [], '-')) == ['--shout', '--no-shout']
+
+
+def test_multi_value_option():
+    @click.group()
+    @click.option('--pos', nargs=2, type=float)
+    def cli(local_opt):
+        pass
+
+    @cli.command()
+    @click.option('--local-opt')
+    def sub(local_opt):
+        pass
+
+    assert list(get_choices(cli, 'lol', [], '-')) == ['--pos']
+    assert list(get_choices(cli, 'lol', ['--pos'], '')) == []
+    assert list(get_choices(cli, 'lol', ['--pos', '1.0'], '')) == []
+    assert list(get_choices(cli, 'lol', ['--pos', '1.0', '1.0'], '')) == ['sub']
+
+
+def test_multi_option():
+    @click.command()
+    @click.option('--message', '-m', multiple=True)
+    def cli(local_opt):
+        pass
+
+    assert list(get_choices(cli, 'lol', [], '-')) == ['--message', '-m']
+    assert list(get_choices(cli, 'lol', ['-m'], '')) == []
+
+
 def test_small_chain():
     @click.group()
     @click.option('--global-opt')
@@ -60,3 +96,134 @@ def test_long_chain():
     assert list(get_choices(cli, 'lol', ['asub', 'bsub'], '')) == ['csub']
     assert list(get_choices(cli, 'lol', ['asub', 'bsub', 'csub'], '-')) == ['--csub-opt']
     assert list(get_choices(cli, 'lol', ['asub', 'bsub', 'csub'], '')) == []
+
+
+def test_argument_choice():
+    @click.command()
+    @click.argument('arg1', required=False, type=click.Choice(['arg11', 'arg12']))
+    @click.argument('arg2', required=False, type=click.Choice(['arg21', 'arg22']))
+    @click.argument('arg3', required=False, type=click.Choice(['arg', 'argument']))
+    def cli():
+        pass
+
+    assert list(get_choices(cli, 'lol', [], '')) == ['arg11', 'arg12']
+    assert list(get_choices(cli, 'lol', [], 'arg')) == ['arg11', 'arg12']
+    assert list(get_choices(cli, 'lol', ['arg11'], '')) == ['arg21', 'arg22']
+    assert list(get_choices(cli, 'lol', ['arg12', 'arg21'], '')) == ['arg', 'argument']
+    assert list(get_choices(cli, 'lol', ['arg12', 'arg21'], 'argu')) == ['argument']
+
+
+def test_option_choice():
+    @click.command()
+    @click.option('--opt1', type=click.Choice(['opt11', 'opt12']))
+    @click.option('--opt2', type=click.Choice(['opt21', 'opt22']))
+    @click.option('--opt3', type=click.Choice(['opt', 'option']))
+    def cli():
+        pass
+
+    assert list(get_choices(cli, 'lol', [], '-')) == ['--opt1', '--opt2', '--opt3']
+    assert list(get_choices(cli, 'lol', [], '--opt')) == ['--opt1', '--opt2', '--opt3']
+    assert list(get_choices(cli, 'lol', [], '--opt1=')) == ['opt11', 'opt12']
+    assert list(get_choices(cli, 'lol', [], '--opt2=')) == ['opt21', 'opt22']
+    assert list(get_choices(cli, 'lol', ['--opt2'], '=')) == ['opt21', 'opt22']
+    assert list(get_choices(cli, 'lol', ['--opt2', '='], 'opt')) == ['opt21', 'opt22']
+    assert list(get_choices(cli, 'lol', ['--opt1'], '')) == ['opt11', 'opt12']
+    assert list(get_choices(cli, 'lol', ['--opt2'], '')) == ['opt21', 'opt22']
+    assert list(get_choices(cli, 'lol', ['--opt1', 'opt11', '--opt2'], '')) == ['opt21', 'opt22']
+    assert list(get_choices(cli, 'lol', ['--opt2', 'opt21'], '-')) == ['--opt1', '--opt3']
+    assert list(get_choices(cli, 'lol', ['--opt1', 'opt11'], '-')) == ['--opt2', '--opt3']
+    assert list(get_choices(cli, 'lol', ['--opt1'], 'opt')) == ['opt11', 'opt12']
+    assert list(get_choices(cli, 'lol', ['--opt3'], 'opti')) == ['option']
+
+    assert list(get_choices(cli, 'lol', ['--opt1', 'invalid_opt'], '-')) == ['--opt2', '--opt3']
+
+
+def test_option_and_arg_choice():
+    @click.command()
+    @click.option('--opt1', type=click.Choice(['opt11', 'opt12']))
+    @click.argument('arg1', required=False, type=click.Choice(['arg11', 'arg12']))
+    @click.option('--opt2', type=click.Choice(['opt21', 'opt22']))
+    def cli():
+        pass
+
+    assert list(get_choices(cli, 'lol', ['--opt1'], '')) == ['opt11', 'opt12']
+    assert list(get_choices(cli, 'lol', [''], '--opt1=')) == ['opt11', 'opt12']
+    assert list(get_choices(cli, 'lol', [], '')) == ['arg11', 'arg12']
+    assert list(get_choices(cli, 'lol', ['--opt2'], '')) == ['opt21', 'opt22']
+
+
+def test_boolean_flag_choice():
+    @click.command()
+    @click.option('--shout/--no-shout', default=False)
+    @click.argument('arg', required=False, type=click.Choice(['arg1', 'arg2']))
+    def cli(local_opt):
+        pass
+
+    assert list(get_choices(cli, 'lol', [], '-')) == ['--shout', '--no-shout']
+    assert list(get_choices(cli, 'lol', ['--shout'], '')) == ['arg1', 'arg2']
+
+
+def test_multi_value_option_choice():
+    @click.command()
+    @click.option('--pos', nargs=2, type=click.Choice(['pos1', 'pos2']))
+    @click.argument('arg', required=False, type=click.Choice(['arg1', 'arg2']))
+    def cli(local_opt):
+        pass
+
+    assert list(get_choices(cli, 'lol', ['--pos'], '')) == ['pos1', 'pos2']
+    assert list(get_choices(cli, 'lol', ['--pos', 'pos1'], '')) == ['pos1', 'pos2']
+    assert list(get_choices(cli, 'lol', ['--pos', 'pos1', 'pos2'], '')) == ['arg1', 'arg2']
+    assert list(get_choices(cli, 'lol', ['--pos', 'pos1', 'pos2', 'arg1'], '')) == []
+
+
+def test_multi_option_choice():
+    @click.command()
+    @click.option('--message', '-m', multiple=True, type=click.Choice(['m1', 'm2']))
+    @click.argument('arg', required=False, type=click.Choice(['arg1', 'arg2']))
+    def cli(local_opt):
+        pass
+
+    assert list(get_choices(cli, 'lol', ['-m'], '')) == ['m1', 'm2']
+    assert list(get_choices(cli, 'lol', ['-m', 'm1', '-m'], '')) == ['m1', 'm2']
+    assert list(get_choices(cli, 'lol', ['-m', 'm1'], '')) == ['arg1', 'arg2']
+
+
+def test_variadic_argument_choice():
+    @click.command()
+    @click.argument('src', nargs=-1, type=click.Choice(['src1', 'src2']))
+    def cli(local_opt):
+        pass
+
+    assert list(get_choices(cli, 'lol', ['src1', 'src2'], '')) == ['src1', 'src2']
+
+
+def test_long_chain_choice():
+    @click.group()
+    def cli():
+        pass
+
+    @cli.group('sub')
+    @click.option('--sub-opt', type=click.Choice(['subopt1', 'subopt2']))
+    @click.argument('sub-arg', required=False, type=click.Choice(['subarg1', 'subarg2']))
+    def sub(sub_opt):
+        pass
+
+    @sub.command('bsub')
+    @click.option('--bsub-opt', type=click.Choice(['bsubopt1', 'bsubopt2']))
+    @click.argument('bsub-arg1', required=False, type=click.Choice(['bsubarg1', 'bsubarg2']))
+    @click.argument('bbsub-arg2', required=False, type=click.Choice(['bbsubarg1', 'bbsubarg2']))
+    def bsub(bsub_opt):
+        pass
+
+    assert list(get_choices(cli, 'lol', ['sub'], '')) == ['subarg1', 'subarg2']
+    assert list(get_choices(cli, 'lol', ['sub', '--sub-opt'], '')) == ['subopt1', 'subopt2']
+    assert list(get_choices(cli, 'lol', ['sub', '--sub-opt', 'subopt1'], '')) == \
+        ['subarg1', 'subarg2']
+    assert list(get_choices(cli, 'lol',
+        ['sub', '--sub-opt', 'subopt1', 'subarg1', 'bsub'], '-')) == ['--bsub-opt']
+    assert list(get_choices(cli, 'lol',
+        ['sub', '--sub-opt', 'subopt1', 'subarg1', 'bsub', '--bsub-opt'], '')) == \
+        ['bsubopt1', 'bsubopt2']
+    assert list(get_choices(cli, 'lol',
+        ['sub', '--sub-opt', 'subopt1', 'subarg1', 'bsub', '--bsub-opt', 'bsubopt1', 'bsubarg1'],
+                            '')) == ['bbsubarg1', 'bbsubarg2']