Skip to content

Commit 14f2acb

Browse files
Fix: include sub-commands in tab completion
For commands like `pip cache` with sub-commands like `remove`, so that e.g. `pip cache re<TAB>` completes to `pip cache remove`. All the existing commands that used such sub-commands followed the same approach: using a dictionary of names to methods to run, so the implementation is just teaching the `Command` object about this mapping and using it in the autocompletion function. There's no handling for the position of the argument, so e.g. `pip cache re<TAB>` and `pip cache --user re<TAB>` will both complete the final word to `remove`. This is mostly because it was simpler to implement like this, but also I think due to how `optparse` works such invocations are valid, e.g. `pip config --user set global.timeout 60`. This is a feature that may be simpler to implement, or just work out of the box, with some argument parsing libraries, but moving to another such library looks to be quite a bit of work (see discussion[1]). I also took the opportunity to tighten some typing: dropping some use of `Any` Link: #4659 [1] Fixes: #13133
1 parent 24f4600 commit 14f2acb

File tree

7 files changed

+79
-24
lines changed

7 files changed

+79
-24
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Include sub-commands in tab completion.

src/pip/_internal/cli/autocompletion.py

+6
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,12 @@ def autocomplete() -> None:
100100
if option[1] and option[0][:2] == "--":
101101
opt_label += "="
102102
print(opt_label)
103+
104+
# Complete sub-commands (unless one is already given).
105+
if not any(name in cwords for name in subcommand.handler_map()):
106+
for handler_name in subcommand.handler_map():
107+
if handler_name.startswith(current):
108+
print(handler_name)
103109
else:
104110
# show main parser options only when necessary
105111

src/pip/_internal/cli/base_command.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import sys
88
import traceback
99
from optparse import Values
10-
from typing import List, Optional, Tuple
10+
from typing import Callable, Dict, List, Optional, Tuple
1111

1212
from pip._vendor.rich import reconfigure
1313
from pip._vendor.rich import traceback as rich_traceback
@@ -231,3 +231,9 @@ def _main(self, args: List[str]) -> int:
231231
options.cache_dir = None
232232

233233
return self._run_wrapper(level_number, options, args)
234+
235+
def handler_map(self) -> Dict[str, Callable[[Values, List[str]], None]]:
236+
"""
237+
map of names to handler actions for commands with sub-actions
238+
"""
239+
return {}

src/pip/_internal/commands/cache.py

+14-11
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import os
22
import textwrap
33
from optparse import Values
4-
from typing import Any, List
4+
from typing import Callable, Dict, List
55

66
from pip._internal.cli.base_command import Command
77
from pip._internal.cli.status_codes import ERROR, SUCCESS
@@ -49,45 +49,48 @@ def add_options(self) -> None:
4949

5050
self.parser.insert_option_group(0, self.cmd_opts)
5151

52-
def run(self, options: Values, args: List[str]) -> int:
53-
handlers = {
52+
def handler_map(self) -> Dict[str, Callable[[Values, List[str]], None]]:
53+
return {
5454
"dir": self.get_cache_dir,
5555
"info": self.get_cache_info,
5656
"list": self.list_cache_items,
5757
"remove": self.remove_cache_items,
5858
"purge": self.purge_cache,
5959
}
6060

61+
def run(self, options: Values, args: List[str]) -> int:
62+
handler_map = self.handler_map()
63+
6164
if not options.cache_dir:
6265
logger.error("pip cache commands can not function since cache is disabled.")
6366
return ERROR
6467

6568
# Determine action
66-
if not args or args[0] not in handlers:
69+
if not args or args[0] not in handler_map:
6770
logger.error(
6871
"Need an action (%s) to perform.",
69-
", ".join(sorted(handlers)),
72+
", ".join(sorted(handler_map)),
7073
)
7174
return ERROR
7275

7376
action = args[0]
7477

7578
# Error handling happens here, not in the action-handlers.
7679
try:
77-
handlers[action](options, args[1:])
80+
handler_map[action](options, args[1:])
7881
except PipError as e:
7982
logger.error(e.args[0])
8083
return ERROR
8184

8285
return SUCCESS
8386

84-
def get_cache_dir(self, options: Values, args: List[Any]) -> None:
87+
def get_cache_dir(self, options: Values, args: List[str]) -> None:
8588
if args:
8689
raise CommandError("Too many arguments")
8790

8891
logger.info(options.cache_dir)
8992

90-
def get_cache_info(self, options: Values, args: List[Any]) -> None:
93+
def get_cache_info(self, options: Values, args: List[str]) -> None:
9194
if args:
9295
raise CommandError("Too many arguments")
9396

@@ -129,7 +132,7 @@ def get_cache_info(self, options: Values, args: List[Any]) -> None:
129132

130133
logger.info(message)
131134

132-
def list_cache_items(self, options: Values, args: List[Any]) -> None:
135+
def list_cache_items(self, options: Values, args: List[str]) -> None:
133136
if len(args) > 1:
134137
raise CommandError("Too many arguments")
135138

@@ -161,7 +164,7 @@ def format_for_abspath(self, files: List[str]) -> None:
161164
if files:
162165
logger.info("\n".join(sorted(files)))
163166

164-
def remove_cache_items(self, options: Values, args: List[Any]) -> None:
167+
def remove_cache_items(self, options: Values, args: List[str]) -> None:
165168
if len(args) > 1:
166169
raise CommandError("Too many arguments")
167170

@@ -188,7 +191,7 @@ def remove_cache_items(self, options: Values, args: List[Any]) -> None:
188191
logger.verbose("Removed %s", filename)
189192
logger.info("Files removed: %s (%s)", len(files), format_size(bytes_removed))
190193

191-
def purge_cache(self, options: Values, args: List[Any]) -> None:
194+
def purge_cache(self, options: Values, args: List[str]) -> None:
192195
if args:
193196
raise CommandError("Too many arguments")
194197

src/pip/_internal/commands/configuration.py

+9-6
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import os
33
import subprocess
44
from optparse import Values
5-
from typing import Any, List, Optional
5+
from typing import Any, Callable, Dict, List, Optional
66

77
from pip._internal.cli.base_command import Command
88
from pip._internal.cli.status_codes import ERROR, SUCCESS
@@ -93,8 +93,8 @@ def add_options(self) -> None:
9393

9494
self.parser.insert_option_group(0, self.cmd_opts)
9595

96-
def run(self, options: Values, args: List[str]) -> int:
97-
handlers = {
96+
def handler_map(self) -> Dict[str, Callable[[Values, List[str]], None]]:
97+
return {
9898
"list": self.list_values,
9999
"edit": self.open_in_editor,
100100
"get": self.get_name,
@@ -103,11 +103,14 @@ def run(self, options: Values, args: List[str]) -> int:
103103
"debug": self.list_config_values,
104104
}
105105

106+
def run(self, options: Values, args: List[str]) -> int:
107+
handler_map = self.handler_map()
108+
106109
# Determine action
107-
if not args or args[0] not in handlers:
110+
if not args or args[0] not in handler_map:
108111
logger.error(
109112
"Need an action (%s) to perform.",
110-
", ".join(sorted(handlers)),
113+
", ".join(sorted(handler_map)),
111114
)
112115
return ERROR
113116

@@ -131,7 +134,7 @@ def run(self, options: Values, args: List[str]) -> int:
131134

132135
# Error handling happens here, not in the action-handlers.
133136
try:
134-
handlers[action](options, args[1:])
137+
handler_map[action](options, args[1:])
135138
except PipError as e:
136139
logger.error(e.args[0])
137140
return ERROR

src/pip/_internal/commands/index.py

+9-6
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import json
22
import logging
33
from optparse import Values
4-
from typing import Any, Iterable, List, Optional
4+
from typing import Any, Callable, Dict, Iterable, List, Optional
55

66
from pip._vendor.packaging.version import Version
77

@@ -50,24 +50,27 @@ def add_options(self) -> None:
5050
self.parser.insert_option_group(0, index_opts)
5151
self.parser.insert_option_group(0, self.cmd_opts)
5252

53-
def run(self, options: Values, args: List[str]) -> int:
54-
handlers = {
53+
def handler_map(self) -> Dict[str, Callable[[Values, List[str]], None]]:
54+
return {
5555
"versions": self.get_available_package_versions,
5656
}
5757

58+
def run(self, options: Values, args: List[str]) -> int:
59+
handler_map = self.handler_map()
60+
5861
# Determine action
59-
if not args or args[0] not in handlers:
62+
if not args or args[0] not in handler_map:
6063
logger.error(
6164
"Need an action (%s) to perform.",
62-
", ".join(sorted(handlers)),
65+
", ".join(sorted(handler_map)),
6366
)
6467
return ERROR
6568

6669
action = args[0]
6770

6871
# Error handling happens here, not in the action-handlers.
6972
try:
70-
handlers[action](options, args[1:])
73+
handler_map[action](options, args[1:])
7174
except PipError as e:
7275
logger.error(e.args[0])
7376
return ERROR

tests/functional/test_completion.py

+33
Original file line numberDiff line numberDiff line change
@@ -427,3 +427,36 @@ def test_completion_uses_same_executable_name(
427427
expect_stderr=deprecated_python,
428428
)
429429
assert executable_name in result.stdout
430+
431+
432+
@pytest.mark.parametrize(
433+
"subcommand, handler_prefix, expected",
434+
[
435+
("cache", "d", "dir"),
436+
("cache", "in", "info"),
437+
("cache", "l", "list"),
438+
("cache", "re", "remove"),
439+
("cache", "pu", "purge"),
440+
("config", "li", "list"),
441+
("config", "e", "edit"),
442+
("config", "ge", "get"),
443+
("config", "se", "set"),
444+
("config", "unse", "unset"),
445+
("config", "d", "debug"),
446+
("index", "ve", "versions"),
447+
],
448+
)
449+
def test_completion_for_action_handler(
450+
subcommand: str, handler_prefix: str, expected: str, autocomplete: DoAutocomplete
451+
) -> None:
452+
res, _ = autocomplete(f"pip {subcommand} {handler_prefix}", cword="2")
453+
454+
assert [expected] == res.stdout.split()
455+
456+
457+
def test_completion_for_action_handler_handler_not_repeated(
458+
autocomplete: DoAutocomplete,
459+
) -> None:
460+
res, _ = autocomplete("pip cache remove re", cword="3")
461+
462+
assert [] == res.stdout.split()

0 commit comments

Comments
 (0)