summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDavid Lord <davidism@gmail.com>2022-03-28 08:08:28 -0700
committerGitHub <noreply@github.com>2022-03-28 08:08:28 -0700
commitef11be6e49e19a055fe7e5a89f0f1f4062c68dba (patch)
tree69c832d1b503191116c1fa42038d6790badaa151
parentd251cb0abc9b0dbda2402d4d831c18718cfb51bf (diff)
parentf2e579ab187ca8fdfbe6ce86de08f0e9f62fe4ae (diff)
downloadclick-ef11be6e49e19a055fe7e5a89f0f1f4062c68dba.tar.gz
Merge pull request #2041 from spanglerco/shell-completion-option-values
Make shell completion prioritize option values over new options
-rw-r--r--CHANGES.rst2
-rw-r--r--src/click/core.py3
-rw-r--r--src/click/shell_completion.py17
-rw-r--r--tests/test_commands.py59
-rw-r--r--tests/test_context.py8
-rw-r--r--tests/test_parser.py15
-rw-r--r--tests/test_shell_completion.py39
7 files changed, 134 insertions, 9 deletions
diff --git a/CHANGES.rst b/CHANGES.rst
index 91b9ec1..191937d 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -50,6 +50,8 @@ Version 8.1.0
value. :issue:`2124`
- ``to_info_dict`` will not fail if a ``ParamType`` doesn't define a
``name``. :issue:`2168`
+- Shell completion prioritizes option values with option prefixes over
+ new options. :issue:`2040`
Version 8.0.4
diff --git a/src/click/core.py b/src/click/core.py
index 263a5ff..939ad81 100644
--- a/src/click/core.py
+++ b/src/click/core.py
@@ -292,6 +292,8 @@ class Context:
#: must be never propagated to another arguments. This is used
#: to implement nested parsing.
self.protected_args: t.List[str] = []
+ #: the collected prefixes of the command's options.
+ self._opt_prefixes: t.Set[str] = set(parent._opt_prefixes) if parent else set()
if obj is None and parent is not None:
obj = parent.obj
@@ -1385,6 +1387,7 @@ class Command(BaseCommand):
)
ctx.args = args
+ ctx._opt_prefixes.update(parser._opt_prefixes)
return args
def invoke(self, ctx: Context) -> t.Any:
diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py
index 1e9df6f..c17a8e6 100644
--- a/src/click/shell_completion.py
+++ b/src/click/shell_completion.py
@@ -448,17 +448,16 @@ def _is_incomplete_argument(ctx: Context, param: Parameter) -> bool:
)
-def _start_of_option(value: str) -> bool:
+def _start_of_option(ctx: Context, value: str) -> bool:
"""Check if the value looks like the start of an option."""
if not value:
return False
c = value[0]
- # Allow "/" since that starts a path.
- return not c.isalnum() and c != "/"
+ return c in ctx._opt_prefixes
-def _is_incomplete_option(args: t.List[str], param: Parameter) -> bool:
+def _is_incomplete_option(ctx: Context, args: t.List[str], param: Parameter) -> bool:
"""Determine if the given parameter is an option that needs a value.
:param args: List of complete args before the incomplete value.
@@ -467,7 +466,7 @@ def _is_incomplete_option(args: t.List[str], param: Parameter) -> bool:
if not isinstance(param, Option):
return False
- if param.is_flag:
+ if param.is_flag or param.count:
return False
last_option = None
@@ -476,7 +475,7 @@ def _is_incomplete_option(args: t.List[str], param: Parameter) -> bool:
if index + 1 > param.nargs:
break
- if _start_of_option(arg):
+ if _start_of_option(ctx, arg):
last_option = arg
return last_option is not None and last_option in param.opts
@@ -551,7 +550,7 @@ def _resolve_incomplete(
# split and discard the "=" to make completion easier.
if incomplete == "=":
incomplete = ""
- elif "=" in incomplete and _start_of_option(incomplete):
+ elif "=" in incomplete and _start_of_option(ctx, incomplete):
name, _, incomplete = incomplete.partition("=")
args.append(name)
@@ -559,7 +558,7 @@ def _resolve_incomplete(
# even if they start with the option character. If it hasn't been
# given and the incomplete arg looks like an option, the current
# command will provide option name completions.
- if "--" not in args and _start_of_option(incomplete):
+ if "--" not in args and _start_of_option(ctx, incomplete):
return ctx.command, incomplete
params = ctx.command.get_params(ctx)
@@ -567,7 +566,7 @@ def _resolve_incomplete(
# If the last complete arg is an option name with an incomplete
# value, the option will provide value completions.
for param in params:
- if _is_incomplete_option(args, param):
+ if _is_incomplete_option(ctx, args, param):
return param, incomplete
# It's not an option name or value. The first argument without a
diff --git a/tests/test_commands.py b/tests/test_commands.py
index bf6a5db..3a0d4b9 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -352,3 +352,62 @@ def test_deprecated_in_invocation(runner):
result = runner.invoke(deprecated_cmd)
assert "DeprecationWarning:" in result.output
+
+
+def test_command_parse_args_collects_option_prefixes():
+ @click.command()
+ @click.option("+p", is_flag=True)
+ @click.option("!e", is_flag=True)
+ def test(p, e):
+ pass
+
+ ctx = click.Context(test)
+ test.parse_args(ctx, [])
+
+ assert ctx._opt_prefixes == {"-", "--", "+", "!"}
+
+
+def test_group_parse_args_collects_base_option_prefixes():
+ @click.group()
+ @click.option("~t", is_flag=True)
+ def group(t):
+ pass
+
+ @group.command()
+ @click.option("+p", is_flag=True)
+ def command1(p):
+ pass
+
+ @group.command()
+ @click.option("!e", is_flag=True)
+ def command2(e):
+ pass
+
+ ctx = click.Context(group)
+ group.parse_args(ctx, ["command1", "+p"])
+
+ assert ctx._opt_prefixes == {"-", "--", "~"}
+
+
+def test_group_invoke_collects_used_option_prefixes(runner):
+ opt_prefixes = set()
+
+ @click.group()
+ @click.option("~t", is_flag=True)
+ def group(t):
+ pass
+
+ @group.command()
+ @click.option("+p", is_flag=True)
+ @click.pass_context
+ def command1(ctx, p):
+ nonlocal opt_prefixes
+ opt_prefixes = ctx._opt_prefixes
+
+ @group.command()
+ @click.option("!e", is_flag=True)
+ def command2(e):
+ pass
+
+ runner.invoke(group, ["command1"])
+ assert opt_prefixes == {"-", "--", "~", "+"}
diff --git a/tests/test_context.py b/tests/test_context.py
index 98f0835..df8b497 100644
--- a/tests/test_context.py
+++ b/tests/test_context.py
@@ -365,3 +365,11 @@ def test_parameter_source(runner, option_args, invoke_args, expect):
rv = runner.invoke(cli, standalone_mode=False, **invoke_args)
assert rv.return_value == expect
+
+
+def test_propagate_opt_prefixes():
+ parent = click.Context(click.Command("test"))
+ parent._opt_prefixes = {"-", "--", "!"}
+ ctx = click.Context(click.Command("test2"), parent=parent)
+
+ assert ctx._opt_prefixes == {"-", "--", "!"}
diff --git a/tests/test_parser.py b/tests/test_parser.py
index 964f9c8..f694916 100644
--- a/tests/test_parser.py
+++ b/tests/test_parser.py
@@ -1,5 +1,7 @@
import pytest
+import click
+from click.parser import OptionParser
from click.parser import split_arg_string
@@ -15,3 +17,16 @@ from click.parser import split_arg_string
)
def test_split_arg_string(value, expect):
assert split_arg_string(value) == expect
+
+
+def test_parser_default_prefixes():
+ parser = OptionParser()
+ assert parser._opt_prefixes == {"-", "--"}
+
+
+def test_parser_collects_prefixes():
+ ctx = click.Context(click.Command("test"))
+ parser = OptionParser(ctx)
+ click.Option("+p", is_flag=True).add_to_parser(parser, ctx)
+ click.Option("!e", is_flag=True).add_to_parser(parser, ctx)
+ assert parser._opt_prefixes == {"-", "--", "+", "!"}
diff --git a/tests/test_shell_completion.py b/tests/test_shell_completion.py
index 08fa057..57729f5 100644
--- a/tests/test_shell_completion.py
+++ b/tests/test_shell_completion.py
@@ -110,6 +110,45 @@ def test_type_choice():
assert _get_words(cli, ["-c"], "a2") == ["a2"]
+def test_choice_special_characters():
+ cli = Command("cli", params=[Option(["-c"], type=Choice(["!1", "!2", "+3"]))])
+ assert _get_words(cli, ["-c"], "") == ["!1", "!2", "+3"]
+ assert _get_words(cli, ["-c"], "!") == ["!1", "!2"]
+ assert _get_words(cli, ["-c"], "!2") == ["!2"]
+
+
+def test_choice_conflicting_prefix():
+ cli = Command(
+ "cli",
+ params=[
+ Option(["-c"], type=Choice(["!1", "!2", "+3"])),
+ Option(["+p"], is_flag=True),
+ ],
+ )
+ assert _get_words(cli, ["-c"], "") == ["!1", "!2", "+3"]
+ assert _get_words(cli, ["-c"], "+") == ["+p"]
+
+
+def test_option_count():
+ cli = Command("cli", params=[Option(["-c"], count=True)])
+ assert _get_words(cli, ["-c"], "") == []
+ assert _get_words(cli, ["-c"], "-") == ["--help"]
+
+
+def test_option_optional():
+ cli = Command(
+ "cli",
+ add_help_option=False,
+ params=[
+ Option(["--name"], is_flag=False, flag_value="value"),
+ Option(["--flag"], is_flag=True),
+ ],
+ )
+ assert _get_words(cli, ["--name"], "") == []
+ assert _get_words(cli, ["--name"], "-") == ["--flag"]
+ assert _get_words(cli, ["--name", "--flag"], "-") == []
+
+
@pytest.mark.parametrize(
("type", "expect"),
[(File(), "file"), (Path(), "file"), (Path(file_okay=False), "dir")],