From 65da5cc5c4b2e75037811c76d73f742e7ef033c5 Mon Sep 17 00:00:00 2001 From: Nicholas Wiles Date: Wed, 31 May 2017 21:56:12 -0700 Subject: [PATCH 1/6] fix overzealous completion when required options are present --- CHANGES | 3 +++ click/_bashcomplete.py | 55 ++++++++++++++++++++++---------------- tests/test_bashcomplete.py | 9 ++++--- 3 files changed, 40 insertions(+), 27 deletions(-) diff --git a/CHANGES b/CHANGES index c3928da9f..0d7dc63b6 100644 --- a/CHANGES +++ b/CHANGES @@ -25,6 +25,9 @@ Version 7.0 a Unix shell command. See #664. - Fix bug that caused bashcompletion to give inproper completions on chained commands. See #774. +- Fix bug that caused bashcompletion to give inproper completions on + chained commands when a required option/argument was being completed. + See #790. Version 6.8 ----------- diff --git a/click/_bashcomplete.py b/click/_bashcomplete.py index 536b5d783..61ddec233 100644 --- a/click/_bashcomplete.py +++ b/click/_bashcomplete.py @@ -56,6 +56,7 @@ def resolve_ctx(cli, prog_name, args): return ctx + def start_of_option(param_str): """ :param param_str: param_str to check @@ -72,6 +73,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 @@ -91,6 +94,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 @@ -118,6 +123,7 @@ def get_user_autocompletions(ctx, args, incomplete, cmd_param): else: return [] + def get_choices(cli, prog_name, args, incomplete): """ :param cli: command definition @@ -144,35 +150,38 @@ def get_choices(cli, prog_name, args, incomplete): choices = [] found_param = False 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)) + else: + # completion for option values from user supplied values + for param in ctx.command.params: + if is_incomplete_option(all_args, param): + choices.extend(get_user_autocompletions(ctx, all_args, incomplete, 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) + # completion for argument values from user supplied values + if not found_param: + for param in ctx.command.params: + if is_incomplete_argument(ctx.params, param): + choices.extend(get_user_autocompletions(ctx, all_args, incomplete, param)) + # Stop looking for other completions only if this argument is required + found_param = param.required + break + + if not found_param and isinstance(ctx.command, MultiCommand): + # completion for any subcommands + choices.extend(ctx.command.list_commands(ctx)) + + if not found_param: + # 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 = set(ctx.command.list_commands(ctx))-set(ctx.protected_args) + choices.extend(remaining_commands) for item in choices: if item.startswith(incomplete): diff --git a/tests/test_bashcomplete.py b/tests/test_bashcomplete.py index 268e046e8..747c01053 100644 --- a/tests/test_bashcomplete.py +++ b/tests/test_bashcomplete.py @@ -125,7 +125,7 @@ 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 @@ -133,7 +133,8 @@ def bsub(bsub_opt, arg): assert list(get_choices(cli, 'lol', [], '')) == ['asub', 'bsub'] 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', ['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'] @@ -255,10 +256,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', From 38787aa02c43ec68fa3f4a3cd2de347354421683 Mon Sep 17 00:00:00 2001 From: Nicholas Wiles Date: Thu, 1 Jun 2017 08:39:30 -0700 Subject: [PATCH 2/6] Update CHANGES Fix typos in CHANGES. --- CHANGES | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index 0d7dc63b6..86736e852 100644 --- a/CHANGES +++ b/CHANGES @@ -23,9 +23,9 @@ 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. -- Fix bug that caused bashcompletion to give inproper completions on +- Fix bug that caused bashcompletion to give improper completions on chained commands when a required option/argument was being completed. See #790. From 13eda7a5e7154479f1c1b80f110863f9a654e875 Mon Sep 17 00:00:00 2001 From: Nicholas Wiles Date: Fri, 6 Oct 2017 21:23:25 -0700 Subject: [PATCH 3/6] =?UTF-8?q?Allow=20returning=20of=20user=20completions?= =?UTF-8?q?=20that=20don=E2=80=99t=20always=20begin=20with=20the=20incompl?= =?UTF-8?q?ete=20term.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGES | 2 ++ click/_bashcomplete.py | 13 ++++++++---- examples/bashcompletion/bashcompletion.py | 14 +++++++++++-- tests/test_bashcomplete.py | 24 +++++++++++++++++++---- 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/CHANGES b/CHANGES index 58acf1317..456eaf87b 100644 --- a/CHANGES +++ b/CHANGES @@ -29,6 +29,8 @@ Version 7.0 - Fix bug that caused bashcompletion to give inproper 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 ----------- diff --git a/click/_bashcomplete.py b/click/_bashcomplete.py index 61ddec233..99ffb4ae1 100644 --- a/click/_bashcomplete.py +++ b/click/_bashcomplete.py @@ -115,7 +115,7 @@ 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, @@ -148,6 +148,7 @@ def get_choices(cli, prog_name, args, incomplete): incomplete = '' choices = [] + user_choices = [] found_param = False if start_of_option(incomplete): # completions for partial options @@ -159,14 +160,14 @@ def get_choices(cli, prog_name, args, incomplete): # completion for option values from user supplied values for param in ctx.command.params: if is_incomplete_option(all_args, param): - choices.extend(get_user_autocompletions(ctx, all_args, incomplete, param)) + user_choices.extend(get_user_autocompletions(ctx, all_args, incomplete, param)) found_param = True break # completion for argument values from user supplied values if not found_param: for param in ctx.command.params: if is_incomplete_argument(ctx.params, param): - choices.extend(get_user_autocompletions(ctx, all_args, incomplete, param)) + user_choices.extend(get_user_autocompletions(ctx, all_args, incomplete, param)) # Stop looking for other completions only if this argument is required found_param = param.required break @@ -180,14 +181,18 @@ def get_choices(cli, prog_name, args, incomplete): while ctx.parent is not None: ctx = ctx.parent if isinstance(ctx.command, MultiCommand) and ctx.command.chain: - remaining_commands = set(ctx.command.list_commands(ctx))-set(ctx.protected_args) + remaining_commands = sorted(set(ctx.command.list_commands(ctx))-set(ctx.protected_args)) choices.extend(remaining_commands) + for item in user_choices: + yield item + for item in choices: if item.startswith(incomplete): yield item + def do_complete(cli, prog_name): cwords = split_arg_string(os.environ['COMP_WORDS']) cword = int(os.environ['COMP_CWORD']) diff --git a/examples/bashcompletion/bashcompletion.py b/examples/bashcompletion/bashcompletion.py index 8aaf1742b..c483d79e4 100644 --- a/examples/bashcompletion/bashcompletion.py +++ b/examples/bashcompletion/bashcompletion.py @@ -1,12 +1,17 @@ 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) @@ -14,14 +19,19 @@ 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) diff --git a/tests/test_bashcomplete.py b/tests/test_bashcomplete.py index 747c01053..69448e44c 100644 --- a/tests/test_bashcomplete.py +++ b/tests/test_bashcomplete.py @@ -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 @@ -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(): @@ -129,14 +137,22 @@ def asub(asub_opt): 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', ['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(): From 54fec6869037f90b35f2ecad3edf56024cb44d70 Mon Sep 17 00:00:00 2001 From: Nicholas Wiles Date: Fri, 6 Oct 2017 22:01:34 -0700 Subject: [PATCH 4/6] Clean up whitespace with autopep8. --- click/_bashcomplete.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/click/_bashcomplete.py b/click/_bashcomplete.py index 99ffb4ae1..edf6e9073 100644 --- a/click/_bashcomplete.py +++ b/click/_bashcomplete.py @@ -45,14 +45,15 @@ 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 @@ -106,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 @@ -160,14 +162,16 @@ def get_choices(cli, prog_name, args, incomplete): # completion for option values from user supplied values for param in ctx.command.params: if is_incomplete_option(all_args, param): - user_choices.extend(get_user_autocompletions(ctx, all_args, incomplete, param)) + user_choices.extend(get_user_autocompletions( + ctx, all_args, incomplete, param)) found_param = True break # completion for argument values from user supplied values if not found_param: for param in ctx.command.params: if is_incomplete_argument(ctx.params, param): - user_choices.extend(get_user_autocompletions(ctx, all_args, incomplete, param)) + user_choices.extend(get_user_autocompletions( + ctx, all_args, incomplete, param)) # Stop looking for other completions only if this argument is required found_param = param.required break @@ -181,7 +185,8 @@ def get_choices(cli, prog_name, args, incomplete): 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)) + remaining_commands = sorted( + set(ctx.command.list_commands(ctx)) - set(ctx.protected_args)) choices.extend(remaining_commands) for item in user_choices: @@ -192,7 +197,6 @@ def get_choices(cli, prog_name, args, incomplete): yield item - def do_complete(cli, prog_name): cwords = split_arg_string(os.environ['COMP_WORDS']) cword = int(os.environ['COMP_CWORD']) From a02cbb773670e52895c72e993c830b192cc1f10a Mon Sep 17 00:00:00 2001 From: Nicholas Wiles Date: Mon, 9 Oct 2017 17:52:17 -0700 Subject: [PATCH 5/6] Cleanup get_choices() to leverage early returns. --- click/_bashcomplete.py | 80 ++++++++++++++++++++---------------------- 1 file changed, 38 insertions(+), 42 deletions(-) diff --git a/click/_bashcomplete.py b/click/_bashcomplete.py index edf6e9073..6b7e8f3c9 100644 --- a/click/_bashcomplete.py +++ b/click/_bashcomplete.py @@ -126,6 +126,22 @@ def get_user_autocompletions(ctx, args, incomplete, cmd_param): 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 @@ -149,52 +165,32 @@ def get_choices(cli, prog_name, args, incomplete): elif incomplete == WORDBREAK: incomplete = '' - choices = [] - user_choices = [] - found_param = False + completions = [] if start_of_option(incomplete): # 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]) - else: - # completion for option values from user supplied values - for param in ctx.command.params: - if is_incomplete_option(all_args, param): - user_choices.extend(get_user_autocompletions( - ctx, all_args, incomplete, param)) - found_param = True - break - # completion for argument values from user supplied values - if not found_param: - for param in ctx.command.params: - if is_incomplete_argument(ctx.params, param): - user_choices.extend(get_user_autocompletions( - ctx, all_args, incomplete, param)) - # Stop looking for other completions only if this argument is required - found_param = param.required - break - - if not found_param and isinstance(ctx.command, MultiCommand): - # completion for any subcommands - choices.extend(ctx.command.list_commands(ctx)) - - if not found_param: - # 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)) - choices.extend(remaining_commands) - - for item in user_choices: - yield item - - 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): From aae0ae2d87d64ff609279655fdcd02006e07872b Mon Sep 17 00:00:00 2001 From: Nicholas Wiles Date: Mon, 9 Oct 2017 18:21:33 -0700 Subject: [PATCH 6/6] Fix empty return path in get_choices --- click/_bashcomplete.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/click/_bashcomplete.py b/click/_bashcomplete.py index 6b7e8f3c9..e0e739579 100644 --- a/click/_bashcomplete.py +++ b/click/_bashcomplete.py @@ -154,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 '='