Skip to content

Commit 02e0fc2

Browse files
Fix: include sub-actions in tab completion
For commands like `pip cache` with sub-actions like `remove`, so that e.g. `pip cache re<TAB>` completes to `pip cache remove`. All the existing commands that used such sub-actions followed the same approach for: 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`. Similarly, there's no duplication handling so `pip cache remove re<TAB>` will also complete. 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: pypa#4659 [1] Fixes: pypa#13133
1 parent bc553db commit 02e0fc2

File tree

7 files changed

+69
-24
lines changed

7 files changed

+69
-24
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix: include sub-actions in tab completion

src/pip/_internal/cli/autocompletion.py

+4
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ def autocomplete() -> None:
101101
if option[1] and option[0][:2] == "--":
102102
opt_label += "="
103103
print(opt_label)
104+
105+
for handler_name in subcommand.handler_map():
106+
if handler_name.startswith(current):
107+
print(handler_name)
104108
else:
105109
# show main parser options only when necessary
106110

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, List, Optional, Tuple
1111

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

231231
return self._run_wrapper(level_number, options, args)
232+
233+
def handler_map(self) -> dict[str, Callable[[Values, list[str]], None]]:
234+
"""
235+
map of names to handler actions for commands with sub-actions
236+
"""
237+
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, 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, 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,6 +1,6 @@
11
import logging
22
from optparse import Values
3-
from typing import Any, Iterable, List, Optional
3+
from typing import Any, Callable, Iterable, List, Optional
44

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

@@ -45,30 +45,33 @@ def add_options(self) -> None:
4545
self.parser.insert_option_group(0, index_opts)
4646
self.parser.insert_option_group(0, self.cmd_opts)
4747

48-
def run(self, options: Values, args: List[str]) -> int:
49-
handlers = {
48+
def handler_map(self) -> dict[str, Callable[[Values, list[str]], None]]:
49+
return {
5050
"versions": self.get_available_package_versions,
5151
}
5252

53+
def run(self, options: Values, args: List[str]) -> int:
54+
handler_map = self.handler_map()
55+
5356
logger.warning(
5457
"pip index is currently an experimental command. "
5558
"It may be removed/changed in a future release "
5659
"without prior warning."
5760
)
5861

5962
# Determine action
60-
if not args or args[0] not in handlers:
63+
if not args or args[0] not in handler_map:
6164
logger.error(
6265
"Need an action (%s) to perform.",
63-
", ".join(sorted(handlers)),
66+
", ".join(sorted(handler_map)),
6467
)
6568
return ERROR
6669

6770
action = args[0]
6871

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

tests/functional/test_completion.py

+25
Original file line numberDiff line numberDiff line change
@@ -421,3 +421,28 @@ def test_completion_uses_same_executable_name(
421421
expect_stderr=deprecated_python,
422422
)
423423
assert executable_name in result.stdout
424+
425+
426+
@pytest.mark.parametrize(
427+
"subcommand, handler_prefix, expected",
428+
[
429+
("cache", "d", "dir"),
430+
("cache", "in", "info"),
431+
("cache", "l", "list"),
432+
("cache", "re", "remove"),
433+
("cache", "pu", "purge"),
434+
("config", "li", "list"),
435+
("config", "e", "edit"),
436+
("config", "ge", "get"),
437+
("config", "se", "set"),
438+
("config", "unse", "unset"),
439+
("config", "d", "debug"),
440+
("index", "ve", "versions"),
441+
],
442+
)
443+
def test_completion_for_action_handler(
444+
subcommand: str, handler_prefix: str, expected: str, autocomplete: DoAutocomplete
445+
) -> None:
446+
res, _ = autocomplete(f"pip {subcommand} {handler_prefix}", cword="2")
447+
448+
assert [expected] == res.stdout.split()

0 commit comments

Comments
 (0)