Skip to content

Commit

Permalink
Include sub-actions in tab completion
Browse files Browse the repository at this point in the history
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
  • Loading branch information
matthewhughes934 committed Jan 3, 2025
1 parent a837a38 commit c2bd048
Show file tree
Hide file tree
Showing 6 changed files with 68 additions and 24 deletions.
4 changes: 4 additions & 0 deletions src/pip/_internal/cli/autocompletion.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ def autocomplete() -> None:
if option[1] and option[0][:2] == "--":
opt_label += "="
print(opt_label)

for handler_name in subcommand.handler_map():
if handler_name.startswith(current):
print(handler_name)
else:
# show main parser options only when necessary

Expand Down
8 changes: 7 additions & 1 deletion src/pip/_internal/cli/base_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import sys
import traceback
from optparse import Values
from typing import List, Optional, Tuple
from typing import Callable, List, Optional, Tuple

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

return self._run_wrapper(level_number, options, args)

def handler_map(self) -> dict[str, Callable[[Values, list[str]], None]]:
"""
map of names to handler actions for commands with sub-actions
"""
return {}
25 changes: 14 additions & 11 deletions src/pip/_internal/commands/cache.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os
import textwrap
from optparse import Values
from typing import Any, List
from typing import Callable, List

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

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

def run(self, options: Values, args: List[str]) -> int:
handlers = {
def handler_map(self) -> dict[str, Callable[[Values, list[str]], None]]:
return {
"dir": self.get_cache_dir,
"info": self.get_cache_info,
"list": self.list_cache_items,
"remove": self.remove_cache_items,
"purge": self.purge_cache,
}

def run(self, options: Values, args: List[str]) -> int:
handler_map = self.handler_map()

if not options.cache_dir:
logger.error("pip cache commands can not function since cache is disabled.")
return ERROR

# Determine action
if not args or args[0] not in handlers:
if not args or args[0] not in handler_map:
logger.error(
"Need an action (%s) to perform.",
", ".join(sorted(handlers)),
", ".join(sorted(handler_map)),
)
return ERROR

action = args[0]

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

return SUCCESS

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

logger.info(options.cache_dir)

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

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

logger.info(message)

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

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

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

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

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

Expand Down
15 changes: 9 additions & 6 deletions src/pip/_internal/commands/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import os
import subprocess
from optparse import Values
from typing import Any, List, Optional
from typing import Any, Callable, List, Optional

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

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

def run(self, options: Values, args: List[str]) -> int:
handlers = {
def handler_map(self) -> dict[str, Callable[[Values, list[str]], None]]:
return {
"list": self.list_values,
"edit": self.open_in_editor,
"get": self.get_name,
Expand All @@ -103,11 +103,14 @@ def run(self, options: Values, args: List[str]) -> int:
"debug": self.list_config_values,
}

def run(self, options: Values, args: List[str]) -> int:
handler_map = self.handler_map()

# Determine action
if not args or args[0] not in handlers:
if not args or args[0] not in handler_map:
logger.error(
"Need an action (%s) to perform.",
", ".join(sorted(handlers)),
", ".join(sorted(handler_map)),
)
return ERROR

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

# Error handling happens here, not in the action-handlers.
try:
handlers[action](options, args[1:])
handler_map[action](options, args[1:])
except PipError as e:
logger.error(e.args[0])
return ERROR
Expand Down
15 changes: 9 additions & 6 deletions src/pip/_internal/commands/index.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import logging
from optparse import Values
from typing import Any, Iterable, List, Optional
from typing import Any, Callable, Iterable, List, Optional

from pip._vendor.packaging.version import Version

Expand Down Expand Up @@ -45,30 +45,33 @@ def add_options(self) -> None:
self.parser.insert_option_group(0, index_opts)
self.parser.insert_option_group(0, self.cmd_opts)

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

def run(self, options: Values, args: List[str]) -> int:
handler_map = self.handler_map()

logger.warning(
"pip index is currently an experimental command. "
"It may be removed/changed in a future release "
"without prior warning."
)

# Determine action
if not args or args[0] not in handlers:
if not args or args[0] not in handler_map:
logger.error(
"Need an action (%s) to perform.",
", ".join(sorted(handlers)),
", ".join(sorted(handler_map)),
)
return ERROR

action = args[0]

# Error handling happens here, not in the action-handlers.
try:
handlers[action](options, args[1:])
handler_map[action](options, args[1:])
except PipError as e:
logger.error(e.args[0])
return ERROR
Expand Down
25 changes: 25 additions & 0 deletions tests/functional/test_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -421,3 +421,28 @@ def test_completion_uses_same_executable_name(
expect_stderr=deprecated_python,
)
assert executable_name in result.stdout


@pytest.mark.parametrize(
"subcommand, handler_prefix, expected",
[
("cache", "d", "dir"),
("cache", "in", "info"),
("cache", "l", "list"),
("cache", "re", "remove"),
("cache", "pu", "purge"),
("config", "li", "list"),
("config", "e", "edit"),
("config", "ge", "get"),
("config", "se", "set"),
("config", "unse", "unset"),
("config", "d", "debug"),
("index", "ve", "versions"),
],
)
def test_completion_for_action_handler(
subcommand: str, handler_prefix: str, expected: str, autocomplete: DoAutocomplete
) -> None:
res, _ = autocomplete(f"pip {subcommand} {handler_prefix}", cword="2")

assert [expected] == res.stdout.split()

0 comments on commit c2bd048

Please sign in to comment.