Repository: pallets/click
Pull request #755 (2017-03-27 04:17:06)

Dynamic bash autocompletion
This PR enables arguments to supply an auto completion callback that dynamically generates completion options. This allows Click to complete arguments that are dynamic in nature like git branch names, database variables, and any other number of things. Much thanks to @bb4242 for spearheading this change. Brett, feel free to take a look! Feedback is welcome!


Patch:
diff --git a/CHANGES b/CHANGES
index c24e42e..f06bb31 100644
--- a/CHANGES
+++ b/CHANGES
@@ -7,12 +7,13 @@ Version 7.0
 -----------
 
 (upcoming release with new features, release date to be decided)
-
+- Added support for dynamic bash completion from a user-supplied callback.
+  See #755
 - 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): '.
+  parenthesis like 'Choose fruit (apple, orange): '.
 - The exception objects now store unicode properly.
 - Added the ability to hide commands and options from help.
 - Added Float Range in Types.
diff --git a/click/_bashcomplete.py b/click/_bashcomplete.py
index bd1c278..10a9dc2 100644
--- a/click/_bashcomplete.py
+++ b/click/_bashcomplete.py
@@ -8,7 +8,6 @@ from .parser import split_arg_string
 from .core import MultiCommand, Option, Argument
 from .types import Choice
 
-
 WORDBREAK = '='
 
 COMPLETION_SCRIPT = '''
@@ -97,6 +96,22 @@ def is_incomplete_argument(current_params, cmd_param):
         return True
     return False
 
+def get_user_autocompletions(ctx, args, incomplete, cmd_param):
+    """
+    :param ctx: context associated with the parsed command
+    :param args: full list of args
+    :param incomplete: the incomplete text to autocomplete
+    :param cmd_param: command definition
+    :return: all the possible user-specified completions for the param
+    """
+    if isinstance(cmd_param.type, Choice):
+        return cmd_param.type.choices
+    elif cmd_param.autocompletion is not None:
+        return cmd_param.autocompletion(ctx=ctx,
+                                        args=args,
+                                        incomplete=incomplete)
+    else:
+        return []
 
 def get_choices(cli, prog_name, args, incomplete):
     """
@@ -134,16 +149,14 @@ def get_choices(cli, prog_name, args, incomplete):
         # 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)
+                choices.extend(get_user_autocompletions(ctx, all_args, incomplete, cmd_param))
                 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)
+                choices.extend(get_user_autocompletions(ctx, all_args, incomplete, cmd_param))
                 found_param = True
                 break
 
diff --git a/click/core.py b/click/core.py
index 18c1766..5e8d0ee 100644
--- a/click/core.py
+++ b/click/core.py
@@ -1266,7 +1266,8 @@ class Parameter(object):
 
     def __init__(self, param_decls=None, type=None, required=False,
                  default=None, callback=None, nargs=None, metavar=None,
-                 expose_value=True, is_eager=False, envvar=None):
+                 expose_value=True, is_eager=False, envvar=None,
+                 autocompletion=None):
         self.name, self.opts, self.secondary_opts = \
             self._parse_decls(param_decls or (), expose_value)
 
@@ -1289,6 +1290,7 @@ class Parameter(object):
         self.is_eager = is_eager
         self.metavar = metavar
         self.envvar = envvar
+        self.autocompletion =  autocompletion
 
     @property
     def human_readable_name(self):
diff --git a/docs/bashcomplete.rst b/docs/bashcomplete.rst
index 9dd9a99..37d10e6 100644
--- a/docs/bashcomplete.rst
+++ b/docs/bashcomplete.rst
@@ -16,9 +16,6 @@ how to do that, see :ref:`setuptools-integration`.  Also, Click currently
 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.
-
 What it Completes
 -----------------
 
@@ -32,6 +29,33 @@ least a dash has been provided.  Example::
     $ repo clone -<TAB><TAB>
     --deep     --help     --rev      --shallow  -r
 
+Additionally, custom suggestions can be provided for arguments and options with
+the ``autocompletion`` parameter.  ``autocompletion`` should a callback function
+that returns a list of strings. This is useful when the suggestions need to be
+dynamically generated at bash completion time. The callback function will be
+passed 3 keyword arguments:
+
+- ``ctx`` - The current click context.
+- ``args`` - The list of arguments passed in.
+- ``incomplete`` - The partial word that is being completed, as a string.  May
+  be an empty string ``''`` if no characters have been entered yet.
+
+Here is an example of using a callback function to generate dynamic suggestions:
+
+.. click:example::
+
+    import os
+
+    def get_env_vars(ctx, incomplete, cwords, cword):
+        return os.environ.keys()
+
+    @click.command()
+    @click.argument("envvar", type=click.STRING, autocompletion=get_env_vars)
+    def cmd1(envvar):
+        click.echo('Environment variable: %s' % envvar)
+        click.echo('Value: %s' % os.environ[envvar])
+
+
 Activation
 ----------
 
diff --git a/examples/bashcompletion/README b/examples/bashcompletion/README
new file mode 100644
index 0000000..f8a0d51
--- /dev/null
+++ b/examples/bashcompletion/README
@@ -0,0 +1,12 @@
+$ bashcompletion
+
+  bashcompletion is a simple example of an application that
+  tries to autocomplete commands, arguments and options.
+
+  This example requires Click 2.0 or higher.
+
+Usage:
+
+  $ pip install --editable .
+  $ eval "$(_BASHCOMPLETION_COMPLETE=source bashcompletion)"
+  $ bashcompletion --help
diff --git a/examples/bashcompletion/bashcompletion.py b/examples/bashcompletion/bashcompletion.py
new file mode 100644
index 0000000..8aaf174
--- /dev/null
+++ b/examples/bashcompletion/bashcompletion.py
@@ -0,0 +1,31 @@
+import click
+import os
+
+@click.group()
+def cli():
+    pass
+
+def get_env_vars(ctx, args, incomplete):
+    return os.environ.keys()
+
+@cli.command()
+@click.argument("envvar", type=click.STRING, autocompletion=get_env_vars)
+def cmd1(envvar):
+    click.echo('Environment variable: %s' % envvar)
+    click.echo('Value: %s' % os.environ[envvar])
+
+@click.group()
+def group():
+    pass
+
+def list_users(ctx, args, incomplete):
+    # Here you can generate completions dynamically
+    users = ['bob', 'alice']
+    return users
+
+@group.command()
+@click.argument("user", type=click.STRING, autocompletion=list_users)
+def subcmd(user):
+    click.echo('Chosen user is %s' % user)
+
+cli.add_command(group)
diff --git a/examples/bashcompletion/setup.py b/examples/bashcompletion/setup.py
new file mode 100644
index 0000000..ad20081
--- /dev/null
+++ b/examples/bashcompletion/setup.py
@@ -0,0 +1,15 @@
+from setuptools import setup
+
+setup(
+    name='click-example-bashcompletion',
+    version='1.0',
+    py_modules=['bashcompletion'],
+    include_package_data=True,
+    install_requires=[
+        'click',
+    ],
+    entry_points='''
+        [console_scripts]
+        bashcompletion=bashcompletion:cli
+    ''',
+)


Test patch:
diff --git a/tests/test_bashcomplete.py b/tests/test_bashcomplete.py
index 42d2255..f8e91b4 100644
--- a/tests/test_bashcomplete.py
+++ b/tests/test_bashcomplete.py
@@ -83,9 +83,18 @@ def test_long_chain():
     def bsub(bsub_opt):
         pass
 
+    COLORS = ['red', 'green', 'blue']
+    def get_colors(ctx, args, incomplete):
+        for c in COLORS:
+            yield c
+
+    CSUB_OPT_CHOICES = ['foo', 'bar']
+    CSUB_CHOICES = ['bar', 'baz']
     @bsub.command('csub')
-    @click.option('--csub-opt')
-    def csub(csub_opt):
+    @click.option('--csub-opt', type=click.Choice(CSUB_OPT_CHOICES))
+    @click.option('--csub', type=click.Choice(CSUB_CHOICES))
+    @click.argument('color', autocompletion=get_colors)
+    def csub(csub_opt, color):
         pass
 
     assert list(get_choices(cli, 'lol', [], '-')) == ['--cli-opt']
@@ -94,8 +103,13 @@ def test_long_chain():
     assert list(get_choices(cli, 'lol', ['asub'], '')) == ['bsub']
     assert list(get_choices(cli, 'lol', ['asub', 'bsub'], '-')) == ['--bsub-opt']
     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'], '')) == []
+    assert list(get_choices(cli, 'lol', ['asub', 'bsub', 'csub'], '-')) == ['--csub-opt', '--csub']
+    assert list(get_choices(cli, 'lol', ['asub', 'bsub', 'csub', '--csub-opt'], '')) == CSUB_OPT_CHOICES
+    assert list(get_choices(cli, 'lol', ['asub', 'bsub', 'csub'], '--csub')) == ['--csub-opt', '--csub']
+    assert list(get_choices(cli, 'lol', ['asub', 'bsub', 'csub', '--csub'], '')) == CSUB_CHOICES
+    assert list(get_choices(cli, 'lol', ['asub', 'bsub', 'csub', '--csub-opt'], 'f')) == ['foo']
+    assert list(get_choices(cli, 'lol', ['asub', 'bsub', 'csub'], '')) == COLORS
+    assert list(get_choices(cli, 'lol', ['asub', 'bsub', 'csub'], 'b')) == ['blue']
 
 
 def test_argument_choice():