Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix overzealous completion when required options/arguments are being completed #806

Merged
merged 9 commits into from
Oct 10, 2017
7 changes: 6 additions & 1 deletion CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,14 @@ Version 7.0
- ``launch`` now works properly under Cygwin. See #650.
- `CliRunner.invoke` now may receive `args` as a string representing
a Unix shell command. See #664.
- Fix bug that caused bashcompletion to give inproper completions on
- Fix bug that caused bashcompletion to give improper completions on
chained commands. See #774.
- 't' and 'f' are now converted to True and False.
- Fix bug that caused bashcompletion to give improper completions on
chained commands when a required option/argument was being completed.
See #790.
- Allow autocompletion function to determine whether or not to return
completions that start with the incomplete argument.

Version 6.8
-----------
Expand Down
100 changes: 57 additions & 43 deletions click/_bashcomplete.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,19 @@ def resolve_ctx(cli, prog_name, args):
ctx = cli.make_context(prog_name, args, resilient_parsing=True)
args_remaining = ctx.protected_args + ctx.args
while ctx is not None and args_remaining:
if isinstance(ctx.command, MultiCommand):
cmd = ctx.command.get_command(ctx, args_remaining[0])
if cmd is None:
return None
ctx = cmd.make_context(args_remaining[0], args_remaining[1:], parent=ctx, resilient_parsing=True)
args_remaining = ctx.protected_args + ctx.args
else:
ctx = ctx.parent
if isinstance(ctx.command, MultiCommand):
cmd = ctx.command.get_command(ctx, args_remaining[0])
if cmd is None:
return None
ctx = cmd.make_context(
args_remaining[0], args_remaining[1:], parent=ctx, resilient_parsing=True)
args_remaining = ctx.protected_args + ctx.args
else:
ctx = ctx.parent

return ctx


def start_of_option(param_str):
"""
:param param_str: param_str to check
Expand All @@ -72,6 +74,8 @@ def is_incomplete_option(all_args, cmd_param):
corresponds to this cmd_param. In other words whether this cmd_param option can still accept
values
"""
if not isinstance(cmd_param, Option):
return False
if cmd_param.is_flag:
return False
last_option = None
Expand All @@ -91,6 +95,8 @@ def is_incomplete_argument(current_params, cmd_param):
: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
"""
if not isinstance(cmd_param, Argument):
return False
current_param_values = current_params[cmd_param.name]
if current_param_values is None:
return True
Expand All @@ -101,6 +107,7 @@ 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
Expand All @@ -110,14 +117,31 @@ def get_user_autocompletions(ctx, args, incomplete, cmd_param):
:return: all the possible user-specified completions for the param
"""
if isinstance(cmd_param.type, Choice):
return cmd_param.type.choices
return [c for c in cmd_param.type.choices if c.startswith(incomplete)]
elif cmd_param.autocompletion is not None:
return cmd_param.autocompletion(ctx=ctx,
args=args,
incomplete=incomplete)
else:
return []


def add_subcommand_completions(ctx, incomplete, completions_out):
# Add subcommand completions.
if isinstance(ctx.command, MultiCommand):
completions_out.extend(
[c for c in ctx.command.list_commands(ctx) if c.startswith(incomplete)])

# Walk up the context list and add any other completion possibilities from chained commands
while ctx.parent is not None:
ctx = ctx.parent
if isinstance(ctx.command, MultiCommand) and ctx.command.chain:
remaining_commands = sorted(
set(ctx.command.list_commands(ctx)) - set(ctx.protected_args))
completions_out.extend(
[c for c in remaining_commands if c.startswith(incomplete)])


def get_choices(cli, prog_name, args, incomplete):
"""
:param cli: command definition
Expand All @@ -130,7 +154,7 @@ def get_choices(cli, prog_name, args, incomplete):

ctx = resolve_ctx(cli, prog_name, args)
if ctx is None:
return
return []

# In newer versions of bash long opts with '='s are partitioned, but it's easier to parse
# without the '='
Expand All @@ -141,42 +165,32 @@ def get_choices(cli, prog_name, args, incomplete):
elif incomplete == WORDBREAK:
incomplete = ''

choices = []
found_param = False
completions = []
if start_of_option(incomplete):
# completions for options
# completions for partial options
for param in ctx.command.params:
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):
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):
choices.extend(get_user_autocompletions(ctx, all_args, incomplete, cmd_param))
found_param = True
break

if not found_param and isinstance(ctx.command, MultiCommand):
# completion for any subcommands
choices.extend(ctx.command.list_commands(ctx))

if not start_of_option(incomplete) and ctx.parent is not None and isinstance(ctx.parent.command, MultiCommand) and ctx.parent.command.chain:
# completion for chained commands
remaining_comands = set(ctx.parent.command.list_commands(ctx.parent))-set(ctx.parent.protected_args)
choices.extend(remaining_comands)

for item in choices:
if item.startswith(incomplete):
yield item
param_opts = [param_opt for param_opt in param.opts +
param.secondary_opts if param_opt not in all_args or param.multiple]
completions.extend(
[c for c in param_opts if c.startswith(incomplete)])
return completions
# completion for option values from user supplied values
for param in ctx.command.params:
if is_incomplete_option(all_args, param):
return get_user_autocompletions(ctx, all_args, incomplete, param)
# completion for argument values from user supplied values
for param in ctx.command.params:
if is_incomplete_argument(ctx.params, param):
completions.extend(get_user_autocompletions(
ctx, all_args, incomplete, param))
# Stop looking for other completions only if this argument is required.
if param.required:
return completions
break

add_subcommand_completions(ctx, incomplete, completions)
return completions


def do_complete(cli, prog_name):
Expand Down
14 changes: 12 additions & 2 deletions examples/bashcompletion/bashcompletion.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,37 @@
import click
import os


@click.group()
def cli():
pass


def get_env_vars(ctx, args, incomplete):
return os.environ.keys()
for key in os.environ.keys():
if incomplete in key:
yield key


@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
for user in users:
if user.startswith(incomplete):
yield user


@group.command()
@click.argument("user", type=click.STRING, autocompletion=list_users)
Expand Down
33 changes: 25 additions & 8 deletions tests/test_bashcomplete.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,20 @@ def bsub(bsub_opt):
COLORS = ['red', 'green', 'blue']
def get_colors(ctx, args, incomplete):
for c in COLORS:
yield c
if c.startswith(incomplete):
yield c

def search_colors(ctx, args, incomplete):
for c in COLORS:
if incomplete in c:
yield c

CSUB_OPT_CHOICES = ['foo', 'bar']
CSUB_CHOICES = ['bar', 'baz']
@bsub.command('csub')
@click.option('--csub-opt', type=click.Choice(CSUB_OPT_CHOICES))
@click.option('--csub', type=click.Choice(CSUB_CHOICES))
@click.option('--search-color', autocompletion=search_colors)
@click.argument('color', autocompletion=get_colors)
def csub(csub_opt, color):
pass
Expand All @@ -103,13 +110,14 @@ def csub(csub_opt, color):
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', '--csub']
assert list(get_choices(cli, 'lol', ['asub', 'bsub', 'csub'], '-')) == ['--csub-opt', '--csub', '--search-color']
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']
assert list(get_choices(cli, 'lol', ['asub', 'bsub', 'csub', '--search-color'], 'een')) == ['green']


def test_chaining():
Expand All @@ -125,17 +133,26 @@ def asub(asub_opt):

@cli.command('bsub')
@click.option('--bsub-opt')
@click.argument('arg', type=click.Choice(['arg1', 'arg2']))
@click.argument('arg', type=click.Choice(['arg1', 'arg2']), required=True)
def bsub(bsub_opt, arg):
pass

@cli.command('csub')
@click.option('--csub-opt')
@click.argument('arg', type=click.Choice(['carg1', 'carg2']), required=False)
def csub(csub_opt, arg):
pass

assert list(get_choices(cli, 'lol', [], '-')) == ['--cli-opt']
assert list(get_choices(cli, 'lol', [], '')) == ['asub', 'bsub']
assert list(get_choices(cli, 'lol', [], '')) == ['asub', 'bsub', 'csub']
assert list(get_choices(cli, 'lol', ['asub'], '-')) == ['--asub-opt']
assert list(get_choices(cli, 'lol', ['asub'], '')) == ['bsub']
assert list(get_choices(cli, 'lol', ['bsub'], '')) == ['arg1', 'arg2', 'asub']
assert list(get_choices(cli, 'lol', ['asub'], '')) == ['bsub', 'csub']
assert list(get_choices(cli, 'lol', ['bsub'], '')) == ['arg1', 'arg2']
assert list(get_choices(cli, 'lol', ['asub', '--asub-opt'], '')) == []
assert list(get_choices(cli, 'lol', ['asub', '--asub-opt', '5', 'bsub'], '-')) == ['--bsub-opt']
assert list(get_choices(cli, 'lol', ['asub', 'bsub'], '-')) == ['--bsub-opt']
assert list(get_choices(cli, 'lol', ['asub', 'csub'], '')) == ['carg1', 'carg2', 'bsub']
assert list(get_choices(cli, 'lol', ['asub', 'csub'], '-')) == ['--csub-opt']


def test_argument_choice():
Expand Down Expand Up @@ -255,10 +272,10 @@ def sub(sub_opt):
def bsub(bsub_opt):
pass

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