Skip to content

Commit

Permalink
Issue #535: Bash completion for click.Choice()
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
pgkelley4 committed Nov 4, 2016
1 parent 67f39e4 commit e0ceb1b
Show file tree
Hide file tree
Showing 4 changed files with 261 additions and 11 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ build
docs/_build
click.egg-info
.tox
.cache
.idea
103 changes: 95 additions & 8 deletions click/_bashcomplete.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
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


COMPLETION_SCRIPT = '''
Expand All @@ -29,6 +33,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
Expand All @@ -39,20 +50,96 @@ 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 not param_str[:1].isalnum()


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
if all_args and start_of_option(all_args[-1]):
last_option = all_args[-1]
if cmd_param.nargs > 1:
for index, arg_str in enumerate(reversed(all_args)):
if index + 1 > cmd_param.nargs:
break
elif 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 paramter
: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

choices = []
if incomplete and not incomplete[:1].isalnum():
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):
choices.extend(ctx.command.list_commands(ctx))
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])
else:
found_option = False
for cmd_param in ctx.command.params:
if isinstance(cmd_param, Option) and is_incomplete_option(all_args, cmd_param):
# completion for option values by choices
if isinstance(cmd_param.type, Choice):
choices.extend(cmd_param.type.choices)
found_option = True
break
elif isinstance(cmd_param, Argument) and is_incomplete_argument(ctx.params, cmd_param):
# completion for argument values by choices
if isinstance(cmd_param.type, Choice):
choices.extend(cmd_param.type.choices)
break

# If we found an option that needs choices, don't present the next command as an option!
if not found_option and isinstance(ctx.command, MultiCommand):
# completion for any subcommands
choices.extend(ctx.command.list_commands(ctx))

for item in choices:
if item.startswith(incomplete):
Expand Down
19 changes: 16 additions & 3 deletions docs/bashcomplete.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,18 @@ 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.

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
as well as any option and argument values where the type is click.Choice.
Subcommands and choices are always listed whereas parameters only if at
least a dash has been provided. Example::

$ repo <TAB><TAB>
Expand Down Expand Up @@ -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.
148 changes: 148 additions & 0 deletions tests/test_bashcomplete.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,42 @@ def cli(local_opt):
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')
Expand Down Expand Up @@ -60,3 +96,115 @@ def csub(csub_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'], '')) == []


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', ['--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_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', 'bsub']
assert list(get_choices(cli, 'lol', ['sub', '--sub-opt'], '')) == ['subopt1', 'subopt2']
assert list(get_choices(cli, 'lol', ['sub', '--sub-opt', 'subopt1'], '')) == \
['subarg1', 'subarg2', 'bsub']
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']

0 comments on commit e0ceb1b

Please sign in to comment.