Skip to content

Commit ef11be6

Browse files
authored
Merge pull request #2041 from spanglerco/shell-completion-option-values
Make shell completion prioritize option values over new options
2 parents d251cb0 + f2e579a commit ef11be6

7 files changed

Lines changed: 134 additions & 9 deletions

File tree

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ Version 8.1.0
5050
value. :issue:`2124`
5151
- ``to_info_dict`` will not fail if a ``ParamType`` doesn't define a
5252
``name``. :issue:`2168`
53+
- Shell completion prioritizes option values with option prefixes over
54+
new options. :issue:`2040`
5355

5456

5557
Version 8.0.4

src/click/core.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,8 @@ def __init__(
292292
#: must be never propagated to another arguments. This is used
293293
#: to implement nested parsing.
294294
self.protected_args: t.List[str] = []
295+
#: the collected prefixes of the command's options.
296+
self._opt_prefixes: t.Set[str] = set(parent._opt_prefixes) if parent else set()
295297

296298
if obj is None and parent is not None:
297299
obj = parent.obj
@@ -1385,6 +1387,7 @@ def parse_args(self, ctx: Context, args: t.List[str]) -> t.List[str]:
13851387
)
13861388

13871389
ctx.args = args
1390+
ctx._opt_prefixes.update(parser._opt_prefixes)
13881391
return args
13891392

13901393
def invoke(self, ctx: Context) -> t.Any:

src/click/shell_completion.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -448,17 +448,16 @@ def _is_incomplete_argument(ctx: Context, param: Parameter) -> bool:
448448
)
449449

450450

451-
def _start_of_option(value: str) -> bool:
451+
def _start_of_option(ctx: Context, value: str) -> bool:
452452
"""Check if the value looks like the start of an option."""
453453
if not value:
454454
return False
455455

456456
c = value[0]
457-
# Allow "/" since that starts a path.
458-
return not c.isalnum() and c != "/"
457+
return c in ctx._opt_prefixes
459458

460459

461-
def _is_incomplete_option(args: t.List[str], param: Parameter) -> bool:
460+
def _is_incomplete_option(ctx: Context, args: t.List[str], param: Parameter) -> bool:
462461
"""Determine if the given parameter is an option that needs a value.
463462
464463
: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:
467466
if not isinstance(param, Option):
468467
return False
469468

470-
if param.is_flag:
469+
if param.is_flag or param.count:
471470
return False
472471

473472
last_option = None
@@ -476,7 +475,7 @@ def _is_incomplete_option(args: t.List[str], param: Parameter) -> bool:
476475
if index + 1 > param.nargs:
477476
break
478477

479-
if _start_of_option(arg):
478+
if _start_of_option(ctx, arg):
480479
last_option = arg
481480

482481
return last_option is not None and last_option in param.opts
@@ -551,23 +550,23 @@ def _resolve_incomplete(
551550
# split and discard the "=" to make completion easier.
552551
if incomplete == "=":
553552
incomplete = ""
554-
elif "=" in incomplete and _start_of_option(incomplete):
553+
elif "=" in incomplete and _start_of_option(ctx, incomplete):
555554
name, _, incomplete = incomplete.partition("=")
556555
args.append(name)
557556

558557
# The "--" marker tells Click to stop treating values as options
559558
# even if they start with the option character. If it hasn't been
560559
# given and the incomplete arg looks like an option, the current
561560
# command will provide option name completions.
562-
if "--" not in args and _start_of_option(incomplete):
561+
if "--" not in args and _start_of_option(ctx, incomplete):
563562
return ctx.command, incomplete
564563

565564
params = ctx.command.get_params(ctx)
566565

567566
# If the last complete arg is an option name with an incomplete
568567
# value, the option will provide value completions.
569568
for param in params:
570-
if _is_incomplete_option(args, param):
569+
if _is_incomplete_option(ctx, args, param):
571570
return param, incomplete
572571

573572
# It's not an option name or value. The first argument without a

tests/test_commands.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,3 +352,62 @@ def deprecated_cmd():
352352

353353
result = runner.invoke(deprecated_cmd)
354354
assert "DeprecationWarning:" in result.output
355+
356+
357+
def test_command_parse_args_collects_option_prefixes():
358+
@click.command()
359+
@click.option("+p", is_flag=True)
360+
@click.option("!e", is_flag=True)
361+
def test(p, e):
362+
pass
363+
364+
ctx = click.Context(test)
365+
test.parse_args(ctx, [])
366+
367+
assert ctx._opt_prefixes == {"-", "--", "+", "!"}
368+
369+
370+
def test_group_parse_args_collects_base_option_prefixes():
371+
@click.group()
372+
@click.option("~t", is_flag=True)
373+
def group(t):
374+
pass
375+
376+
@group.command()
377+
@click.option("+p", is_flag=True)
378+
def command1(p):
379+
pass
380+
381+
@group.command()
382+
@click.option("!e", is_flag=True)
383+
def command2(e):
384+
pass
385+
386+
ctx = click.Context(group)
387+
group.parse_args(ctx, ["command1", "+p"])
388+
389+
assert ctx._opt_prefixes == {"-", "--", "~"}
390+
391+
392+
def test_group_invoke_collects_used_option_prefixes(runner):
393+
opt_prefixes = set()
394+
395+
@click.group()
396+
@click.option("~t", is_flag=True)
397+
def group(t):
398+
pass
399+
400+
@group.command()
401+
@click.option("+p", is_flag=True)
402+
@click.pass_context
403+
def command1(ctx, p):
404+
nonlocal opt_prefixes
405+
opt_prefixes = ctx._opt_prefixes
406+
407+
@group.command()
408+
@click.option("!e", is_flag=True)
409+
def command2(e):
410+
pass
411+
412+
runner.invoke(group, ["command1"])
413+
assert opt_prefixes == {"-", "--", "~", "+"}

tests/test_context.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,3 +365,11 @@ def cli(ctx, option):
365365

366366
rv = runner.invoke(cli, standalone_mode=False, **invoke_args)
367367
assert rv.return_value == expect
368+
369+
370+
def test_propagate_opt_prefixes():
371+
parent = click.Context(click.Command("test"))
372+
parent._opt_prefixes = {"-", "--", "!"}
373+
ctx = click.Context(click.Command("test2"), parent=parent)
374+
375+
assert ctx._opt_prefixes == {"-", "--", "!"}

tests/test_parser.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import pytest
22

3+
import click
4+
from click.parser import OptionParser
35
from click.parser import split_arg_string
46

57

@@ -15,3 +17,16 @@
1517
)
1618
def test_split_arg_string(value, expect):
1719
assert split_arg_string(value) == expect
20+
21+
22+
def test_parser_default_prefixes():
23+
parser = OptionParser()
24+
assert parser._opt_prefixes == {"-", "--"}
25+
26+
27+
def test_parser_collects_prefixes():
28+
ctx = click.Context(click.Command("test"))
29+
parser = OptionParser(ctx)
30+
click.Option("+p", is_flag=True).add_to_parser(parser, ctx)
31+
click.Option("!e", is_flag=True).add_to_parser(parser, ctx)
32+
assert parser._opt_prefixes == {"-", "--", "+", "!"}

tests/test_shell_completion.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,45 @@ def test_type_choice():
110110
assert _get_words(cli, ["-c"], "a2") == ["a2"]
111111

112112

113+
def test_choice_special_characters():
114+
cli = Command("cli", params=[Option(["-c"], type=Choice(["!1", "!2", "+3"]))])
115+
assert _get_words(cli, ["-c"], "") == ["!1", "!2", "+3"]
116+
assert _get_words(cli, ["-c"], "!") == ["!1", "!2"]
117+
assert _get_words(cli, ["-c"], "!2") == ["!2"]
118+
119+
120+
def test_choice_conflicting_prefix():
121+
cli = Command(
122+
"cli",
123+
params=[
124+
Option(["-c"], type=Choice(["!1", "!2", "+3"])),
125+
Option(["+p"], is_flag=True),
126+
],
127+
)
128+
assert _get_words(cli, ["-c"], "") == ["!1", "!2", "+3"]
129+
assert _get_words(cli, ["-c"], "+") == ["+p"]
130+
131+
132+
def test_option_count():
133+
cli = Command("cli", params=[Option(["-c"], count=True)])
134+
assert _get_words(cli, ["-c"], "") == []
135+
assert _get_words(cli, ["-c"], "-") == ["--help"]
136+
137+
138+
def test_option_optional():
139+
cli = Command(
140+
"cli",
141+
add_help_option=False,
142+
params=[
143+
Option(["--name"], is_flag=False, flag_value="value"),
144+
Option(["--flag"], is_flag=True),
145+
],
146+
)
147+
assert _get_words(cli, ["--name"], "") == []
148+
assert _get_words(cli, ["--name"], "-") == ["--flag"]
149+
assert _get_words(cli, ["--name", "--flag"], "-") == []
150+
151+
113152
@pytest.mark.parametrize(
114153
("type", "expect"),
115154
[(File(), "file"), (Path(), "file"), (Path(file_okay=False), "dir")],

0 commit comments

Comments
 (0)