diff --git a/news/11409.feature.rst b/news/11409.feature.rst new file mode 100644 index 00000000000..d8da07c4588 --- /dev/null +++ b/news/11409.feature.rst @@ -0,0 +1 @@ +Add zsh completion. diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index 84e0e783869..f2a4b501001 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -300,18 +300,27 @@ class PipOption(Option): ) +EXISTS_ACTIONS = { + "s": "switch", + "i": "ignore", + "w": "wipe", + "b": "backup", + "a": "abort", +} + + def exists_action() -> Option: return Option( # Option when path already exist "--exists-action", dest="exists_action", type="choice", - choices=["s", "i", "w", "b", "a"], + choices=list(EXISTS_ACTIONS.keys()), default=[], action="append", metavar="action", help="Default action when a path already exists: " - "(s)witch, (i)gnore, (w)ipe, (b)ackup, (a)bort.", + ", ".join(EXISTS_ACTIONS.values()), ) @@ -505,6 +514,7 @@ def no_binary() -> Option: action="callback", callback=_handle_no_binary, type="str", + metavar="binary", default=format_control, help="Do not use binary packages. Can be supplied multiple times, and " 'each time adds to the existing value. Accepts either ":all:" to ' @@ -523,6 +533,7 @@ def only_binary() -> Option: action="callback", callback=_handle_only_binary, type="str", + metavar="binary", default=format_control, help="Do not use source packages. Can be supplied multiple times, and " 'each time adds to the existing value. Accepts either ":all:" to ' @@ -723,6 +734,7 @@ def _handle_no_cache_dir( "--no-cache-dir", dest="cache_dir", action="callback", + metavar="", callback=_handle_no_cache_dir, help="Disable the cache.", ) diff --git a/tools/zsh_completion.in b/tools/zsh_completion.in new file mode 100644 index 00000000000..c0bb5db2f74 --- /dev/null +++ b/tools/zsh_completion.in @@ -0,0 +1,57 @@ +#compdef -P {{program}}[0-9.]# +# https://github.com/zsh-users/zsh/blob/master/Etc/completion-style-guide +# https://github.com/zsh-users/zsh/blob/master/Completion/Unix/Command/_pip +# To get completion of installable packages, do: +# pip install pip-cache +# pip-cache update + +_arguments -s -S \ + {{flags}} \ + ': :->command' \ + '*:: :->options' + +local _commands=( + {{commands}} +) +case $state in + 'command') + _describe command _commands + ;; + 'options') + local curcontext="${curcontext%:*:*}:{{program}}-${words[1]}": + case "${words[1]}" in +{{cases}} + help) + _describe command _commands + ;; + *) + _message 'unknown command: '"${words[1]}" + ;; + esac + + case $state in + path/url) + _alternative ': :_files' ': :_urls' + ;; + + package_list) + packages=(${(f)"$(_call_program packages pip-cache pkgnames)"}) + _sequence _wanted packages expl package compadd - -a packages + ;; + + packages_or_dirs) + [[ -prefix - ]] || packages=(git+ hg+ svn+ + ${(f)"$(_call_program packages pip-cache pkgnames)"}) + _alternative \ + 'all-packages:package:compadd -a packages' \ + 'directories:directory with setup.py:_directories' + ;; + + installed_packages) + packages=($(_call_program fetch-installed \ + "env COMP_WORDS='pip uninstall' COMP_CWORD=2 PIP_AUTO_COMPLETE=1 $pip")) + _wanted installed-packages expl 'installed package' compadd -a packages + ;; + esac + ;; +esac diff --git a/tools/zsh_completion.py b/tools/zsh_completion.py new file mode 100755 index 00000000000..1be5de401ff --- /dev/null +++ b/tools/zsh_completion.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python +"""Generate zsh completion script. + +Usage +----- +.. code-block:: zsh + scripts/zsh_completion.py + sudo mv _[^_]* /usr/share/zsh/site-functions # don't mv __pycache__ + rm -f ~/.zcompdump # optional + compinit # regenerate ~/.zcompdump + +Refer +----- +- https://github.com/ytdl-org/youtube-dl/blob/master/devscripts/zsh-completion.py +- https://github.com/zsh-users/zsh/blob/master/Etc/completion-style-guide +- https://gist.githubusercontent.com/zeroSteiner/215953f2fe54ed75e8159125fabe2aba/raw/d746c8da027b738b16118ee4313d048c9f60aa11/zpycompletion.py # noqa: E501 + +Examples +-------- +.. code-block:: + '(- *)'{-h,--help}'[show this help message and exit]' + |<-1->||<---2--->||<---------------3--------------->| + +.. code-block:: console + % foo -- + option + --help show this help message and exit + % foo --help + no more arguments + +.. code-block:: + --color'[When to show color. Default: auto. Support: auto, always, never]:when:(auto always never)' # noqa: E501 + |<-2->||<------------------------------3------------------------------->||<4>||<--------5-------->| # noqa: E501 + +.. code-block:: console + % foo --color + when + always + auto + never + +.. code-block:: + --config='[Config file. Default: ~/.config/foo/foo.toml]:config file:_files -g *.toml' # noqa: E501 + |<--2-->||<---------------------3--------------------->||<---4---->||<------5------->| # noqa: E501 + +.. code-block:: console + % foo --config + config file + a.toml b/ ... + ... + +.. code-block:: + {1,2}'::_command_names -e' + |<2->|4|<-------5------->| + +.. code-block:: console + % foo help + _command_names -e + help2man generate a simple manual page + helpviewer + ... + % foo hello hello + no more arguments + +.. code-block:: + '*: :_command_names -e' + 2|4||<-------5------->| + +.. code-block:: console + % foo help + external command + help2man generate a simple manual page + helpviewer + ... + % foo hello hello help + external command + help2man generate a simple manual page + helpviewer + ... + ++----+------------+----------+------+ +| id | variable | required | expr | ++====+============+==========+======+ +| 1 | prefix | F | (.*) | +| 2 | optionstr | T | .* | +| 3 | helpstr | F | [.*] | +| 4 | metavar | F | :.* | +| 5 | completion | F | :.* | ++----+------------+----------+------+ +""" +import os +import sys +from importlib import import_module +from optparse import SUPPRESS_HELP +from os.path import dirname as dirn +from typing import TYPE_CHECKING, Final + +from setuptools import find_packages + +if TYPE_CHECKING: + from optparse import Option + +rootpath = dirn(dirn(os.path.abspath(__file__))) +path = os.path.join(rootpath, "src") +packages = find_packages(path) +if packages == []: + path = rootpath + packages = find_packages(path) +sys.path.insert(0, path) +from pip._internal.cli.main_parser import create_main_parser # noqa: E402 +from pip._internal.commands import commands_dict # noqa: E402 + +parser = create_main_parser() +actions = parser._get_all_options() +PACKAGES: Final = packages +PACKAGE: Final = PACKAGES[0] +BINNAME: Final = PACKAGE.replace("_", "-") +ZSH_COMPLETION_FILE: Final = "_" + BINNAME if sys.argv[2:3] == [] else sys.argv[2] +ZSH_COMPLETION_TEMPLATE: Final = os.path.join( + dirn(os.path.abspath(__file__)), "zsh_completion.in" +) + +case_template = """\ + '{{metavar}}') + _arguments -s -S \\ + {{flags}} + ;; +""" +subparser = "" +commands = [] +cases = [] + + +def generate_flag(action: "Option") -> str: + """generate_flag. + + :param action: + :type action: "Option" + :rtype: str + """ + if action.dest in ["help", "version"]: + prefix = "'(- : *)'" + else: + prefix = "" + + option_strings = action._short_opts + action._long_opts + if len(option_strings) > 1: # {} cannot be quoted + optionstr = "{" + ",".join(option_strings) + "}'" + elif len(option_strings) == 1: + optionstr = option_strings[0] + "'" + else: # action.option_strings == [], positional argument + if action.nargs in ["*", "+"]: + optionstr = "'*" # * must be quoted + else: + if isinstance(action.nargs, int) and action.nargs > 1: + optionstr = "{" + "," * (action.nargs - 1) + "}'" + else: # action.nargs in [1, None, "?"]: + optionstr = "'" + + if action.help and action.help != SUPPRESS_HELP and option_strings != []: + helpstr = action.help.replace("]", "\\]").replace("'", "'\\''") + helpstr = "[" + helpstr + "]" + else: + helpstr = "" + + if isinstance(action.metavar, str): + metavar = action.metavar + else: # action.metavar is None + if action.nargs is None or action.nargs == 0: + metavar = "" + elif option_strings == [] and action.dest: + metavar = action.dest + elif action.type: + metavar = action.type + else: + metavar = action.default.__class__.__name__ + if metavar != "": + # use lowcase conventionally + metavar = metavar.lower().replace(":", "\\:") + + choices = action.choices # type: ignore + if action.metavar == "binary": + completion = "->package_list" + metavar = " " + elif action.metavar == "platform": + # Not all, just mostly used, for users' convenience + # input other word will not throw error + choices = [ + "any", + "manylinux1_x86_64", + "manylinux1_i386", + "manylinux2014_aarch64", + "win_amd64", + "macosx_10_9_x86_64", + "macosx_11_0_arm64", + ] + completion = "(" + " ".join(choices) + ")" + elif action.metavar == "action": + from pip._internal.cli.cmdoptions import EXISTS_ACTIONS + + completion = " ".join(map("\\:".join, EXISTS_ACTIONS.items())) + completion = "((" + completion + "))" + elif action.metavar == "implementation": + implementations = { + "pp": "pypy", + "jy": "jython", + "cp": "cpython", + "ip": "ironpython", + "py": "implementation-agnostic", + } + completion = " ".join(map("\\:".join, implementations.items())) + completion = "((" + completion + "))" + elif choices: + completion = "(" + " ".join(map(str, choices)) + ")" + elif metavar in ["file", "path"]: + completion = "_files" + metavar = " " + elif metavar == "dir": + completion = "_dirs" + metavar = " " + elif metavar == "url": + completion = "_urls" + metavar = " " + elif metavar == "path/url": + completion = "->path/url" + metavar = " " + elif metavar == "hostname": + completion = "_hostname" + metavar = " " + elif metavar == "command": + completion = "_command_names -e" + metavar = " " + else: + completion = "" + + if metavar != "": + metavar = ":" + metavar + if completion != "": + completion = ":" + completion + + flag = "{0}{1}{2}{3}{4}'".format(prefix, optionstr, helpstr, metavar, completion) + return flag + + +flags = [] +for action in actions: + flag = generate_flag(action) + flags += [flag] + +for metavar, commandinfo in commands_dict.items(): + helpstr = commandinfo.summary.replace("'", "'\\''") + command = "'" + metavar + ":" + helpstr + "'" + commands += [command] + + if metavar == "install": + subflags = ["'*: :->packages_or_dirs'"] + elif metavar in ["uninstall", "show"]: + subflags = ["'*: :->installed_packages'"] + elif metavar == "hash": + subflags = ["'*: :_files'"] + elif metavar == "help": + continue # write manually + else: + subflags = [] + module = import_module(commandinfo.module_path) + command_class = getattr(module, commandinfo.class_name) + subactions = command_class(metavar, helpstr).parser._get_all_options() + for subaction in subactions: + subflag = generate_flag(subaction) + subflags += [subflag] + case = case_template.replace("{{metavar}}", metavar) + case = case.replace("{{flags}}", " \\\n ".join(subflags)) + cases += [case] + +with open(ZSH_COMPLETION_TEMPLATE) as f: + template = f.read() + +template = template.replace("{{flags}}", " \\\n ".join(flags)) + +template = template.replace("{{program}}", BINNAME) +template = template.replace("{{commands}}", "\n ".join(commands)) +template = template.replace("{{cases}}", "\n".join(cases)) + +with open(ZSH_COMPLETION_FILE, "w") as f: + f.write(template)