背景
如何实现python包的命令行(Cli)
pip 代码案例
源码结构
pip/
├── __main__.py # pip 的入口
├── _internal/
│ ├── cli/
│ │ ├── main.py # 定义命令行入口点
│ │ ├── cmdoptions.py # 处理命令行选项
│ │ ├── commands/ # 定义各种命令(如 install, uninstall 等)
│ │ └── parser.py # 解析命令行输入
│ ├── commands/ # 每个命令对应一个文件
│ │ ├── install.py
│ │ ├── uninstall.py
│ │ ├── list.py
│ │ └── ...
│ ├── operations/ # 具体操作的实现(如下载、解压)
│ ├── utils/ # 工具方法
│ └── ...
cli调用链路
pip/__main__.py
import os
import sys
# Remove '' and current working directory from the first entry
# of sys.path, if present to avoid using current directory
# in pip commands check, freeze, install, list and show,
# when invoked as python -m pip <command>
if sys.path[0] in ("", os.getcwd()):
sys.path.pop(0)
# If we are running from a wheel, add the wheel to sys.path
# This allows the usage python pip-*.whl/pip install pip-*.whl
if __package__ == "":
# __file__ is pip-*.whl/pip/__main__.py
# first dirname call strips of '/__main__.py', second strips off '/pip'
# Resulting path is the name of the wheel itself
# Add that to sys.path so we can import pip
path = os.path.dirname(os.path.dirname(__file__))
sys.path.insert(0, path)
if __name__ == "__main__":
from pip._internal.cli.main import main as _main
sys.exit(_main())
当用户通过命令行运行 pip包 时,系统调用这个脚本,它执行了 pip._internal.cli.main.main() 函数,开始整个流程。
pip/_internal/cli/main.py
def main(args: Optional[List[str]] = None) -> int:
"""
pip 的主入口函数,负责解析命令行参数并执行相应的操作。
:param args: 可选参数列表,默认为 sys.argv[1:](即从命令行传入的参数)。
:return: 返回退出状态码,0 表示成功,非零值表示失败或异常。
"""
if args is None:
args = sys.argv[1:]
# Suppress the pkg_resources deprecation warning
# Note - we use a module of .*pkg_resources to cover
# the normal case (pip._vendor.pkg_resources) and the
# devendored case (a bare pkg_resources)
# 抑制 pkg_resources 的弃用警告
# 使用 .*pkg_resources 匹配 pip 内部的 vendor 化模块和外部的 pkg_resources 模块
warnings.filterwarnings(
action="ignore", category=DeprecationWarning, module=".*pkg_resources"
)
# Configure our deprecation warnings to be sent through loggers
deprecation.install_warning_logger()
# 启用命令行自动补全功能
autocomplete()
try:
# 解析命令行参数,返回命令名称和对应的参数列表
cmd_name, cmd_args = parse_command(args)
except PipError as exc:
sys.stderr.write(f"ERROR: {exc}")
sys.stderr.write(os.linesep)
sys.exit(1)
# Needed for locale.getpreferredencoding(False) to work
# in pip._internal.utils.encoding.auto_decode
try:
locale.setlocale(locale.LC_ALL, "")
except locale.Error as e:
# setlocale can apparently crash if locale are uninitialized
logger.debug("Ignoring error %s when setting locale", e)
# 根据解析的命令名称创建命令实例
# isolated 参数表示是否启用隔离模式(忽略环境变量和配置文件的影响)
command = create_command(cmd_name, isolated=("--isolated" in cmd_args))
return command.main(cmd_args)
● 调用 create_command(cmd_name, isolated=(“–isolated” in cmd_args)) 创建对应命令的实例。
○ cmd_name 指定了要执行的命令。
○ isolated 参数表示是否启用隔离模式(即忽略环境变量和配置文件的影响)。
● 调用 command.main(cmd_args) 执行命令,并返回退出状态码。
pip/_internal/commands/__init__.py
def create_command(name: str, **kwargs: Any) -> Command:
"""
根据给定的命令名称,创建并返回一个 Command 类的实例。
:param name: 命令名称(如 "install", "uninstall" 等)。
:param kwargs: 可选的关键字参数,用于传递额外的配置(如 isolated 模式等)。
:return
module_path, class_name, summary = commands_dict[name]
# 动态导入包含命令实现的模块
# module_path 是模块的路径(如 "pip._internal.commands.install")
module = importlib.import_module(module_path)
# 从模块中获取命令类
# class_name 是命令类的名称(如 "InstallCommand")
command_class = getattr(module, class_name)
# 创建命令类的实例
# 将命令名称、简要描述以及额外的关键字参数传递给命令类的构造函数
command = command_class(name=name, summary=summary, **kwargs)
return command
commands_dict 是一个字典,关联命令名称(例如 install) 与对应的 Command 类定义
commands_dict
commands_dict: Dict[str, CommandInfo] = {
"install": CommandInfo(
"pip._internal.commands.install",
"InstallCommand",
"Install packages.",
),
"download": CommandInfo(
"pip._internal.commands.download",
"DownloadCommand",
"Download packages.",
),
"uninstall": CommandInfo(
"pip._internal.commands.uninstall",
"UninstallCommand",
"Uninstall packages.",
),
"freeze": CommandInfo(
"pip._internal.commands.freeze",
"FreezeCommand",
"Output installed packages in requirements format.",
),
"inspect": CommandInfo(
"pip._internal.commands.inspect",
"InspectCommand",
"Inspect the python environment.",
),
"list": CommandInfo(
"pip._internal.commands.list",
"ListCommand",
"List installed packages.",
),
"show": CommandInfo(
"pip._internal.commands.show",
"ShowCommand",
"Show information about installed packages.",
),
"check": CommandInfo(
"pip._internal.commands.check",
"CheckCommand",
"Verify installed packages have compatible dependencies.",
),
"config": CommandInfo(
"pip._internal.commands.configuration",
"ConfigurationCommand",
"Manage local and global configuration.",
),
"search": CommandInfo(
"pip._internal.commands.search",
"SearchCommand",
"Search PyPI for packages.",
),
"cache": CommandInfo(
"pip._internal.commands.cache",
"CacheCommand",
"Inspect and manage pip's wheel cache.",
),
"index": CommandInfo(
"pip._internal.commands.index",
"IndexCommand",
"Inspect information available from package indexes.",
),
"wheel": CommandInfo(
"pip._internal.commands.wheel",
"WheelCommand",
"Build wheels from your requirements.",
),
"hash": CommandInfo(
"pip._internal.commands.hash",
"HashCommand",
"Compute hashes of package archives.",
),
"completion": CommandInfo(
"pip._internal.commands.completion",
"CompletionCommand",
"A helper command used for command completion.",
),
"debug": CommandInfo(
"pip._internal.commands.debug",
"DebugCommand",
"Show information useful for debugging.",
),
"help": CommandInfo(
"pip._internal.commands.help",
"HelpCommand",
"Show help for commands.",
),
}
- getattr 的作用:
- getattr 是 Python 的内置函数,用于从对象中动态获取属性或方法。
- 在这里,getattr(module, class_name) 从模块中动态获取名为 class_name 的类。
- 例如,如果 class_name = “InstallCommand”,则 getattr(module, “InstallCommand”) 返回 module.InstallCommand,即 InstallCommand 类本身。
- 动态获取类的意义:
- 通过 getattr,代码可以动态地从模块中提取类,而无需提前知道类的具体名称。
- 这种机制使得 pip 可以支持任意数量的命令类,只需在 commands_dict 中添加相应的条目即可。
- 调用类构造函数:
- command_class 是通过 getattr 获取到的类(如 InstallCommand)。
- 调用 command_class(name=name, summary=summary, **kwargs) 实际上等价于直接调用类的构造函数。
- 例如:
command = InstallCommand(name="install", summary="Install packages.", **kwargs)
- 构造函数接收以下参数:
- name:命令名称(如 “install”)。
- summary:命令的简要描述。
- **kwargs:额外的关键字参数(如 isolated 模式等),用于配置命令的行为。
- 动态实例化的意义:
- 通过动态加载模块和类,pip 可以根据用户输入的命令名称(如 “install” 或 “uninstall”)创建对应的命令实例。
- 这种方式避免了硬编码所有命令的实例化逻辑,增强了代码的灵活性和可扩展性。
pip/_internal/cli/base_command.py
"""Base Command class, and related routines"""
import logging
import logging.config
import optparse
import os
import sys
import traceback
from optparse import Values
from typing import List, Optional, Tuple
from pip._vendor.rich import reconfigure
from pip._vendor.rich import traceback as rich_traceback
from pip._internal.cli import cmdoptions
from pip._internal.cli.command_context import CommandContextMixIn
from pip._internal.cli.parser import ConfigOptionParser, UpdatingDefaultsHelpFormatter
from pip._internal.cli.status_codes import (
ERROR,
PREVIOUS_BUILD_DIR_ERROR,
UNKNOWN_ERROR,
VIRTUALENV_NOT_FOUND,
)
from pip._internal.exceptions import (
BadCommand,
CommandError,
DiagnosticPipError,
InstallationError,
NetworkConnectionError,
PreviousBuildDirError,
)
from pip._internal.utils.filesystem import check_path_owner
from pip._internal.utils.logging import BrokenStdoutLoggingError, setup_logging
from pip._internal.utils.misc import get_prog, normalize_path
from pip._internal.utils.temp_dir import TempDirectoryTypeRegistry as TempDirRegistry
from pip._internal.utils.temp_dir import global_tempdir_manager, tempdir_registry
from pip._internal.utils.virtualenv import running_under_virtualenv
__all__ = ["Command"]
logger = logging.getLogger(__name__)
class Command(CommandContextMixIn):
usage: str = ""
ignore_require_venv: bool = False
def __init__(self, name: str, summary: str, isolated: bool = False) -> None:
# 1.调用父类构造函数(CommandContextMixIn)。
# 2.初始化命令解析器(ConfigOptionParser),并设置默认选项组。
# 3.创建一个选项组(cmd_opts),供子类添加自定义选项。
# 4.添加通用选项(如 --verbose、--quiet 等)。
# 5.调用 add_options() 方法,允许子类添加特定于命令的选项。
super().__init__()
self.name = name
self.summary = summary
self.parser = ConfigOptionParser(
usage=self.usage,
prog=f"{get_prog()} {name}",
formatter=UpdatingDefaultsHelpFormatter(),
add_help_option=False,
name=name,
description=self.__doc__,
isolated=isolated,
)
self.tempdir_registry: Optional[TempDirRegistry] = None
# Commands should add options to this option group
optgroup_name = f"{self.name.capitalize()} Options"
self.cmd_opts = optparse.OptionGroup(self.parser, optgroup_name)
# Add the general options
gen_opts = cmdoptions.make_option_group(
cmdoptions.general_group,
self.parser,
)
self.parser.add_option_group(gen_opts)
self.add_options()
def add_options(self) -> None:
pass
def handle_pip_version_check(self, options: Values) -> None:
"""
This is a no-op so that commands by default do not do the pip version
check.
"""
# Make sure we do the pip version check if the index_group options
# are present.
assert not hasattr(options, "no_index")
def run(self, options: Values, args: List[str]) -> int:
raise NotImplementedError
def _run_wrapper(self, level_number: int, options: Values, args: List[str]) -> int:
def _inner_run() -> int:
try:
# 调用实现类的run方法
return self.run(options, args)
finally:
self.handle_pip_version_check(options)
if options.debug_mode:
rich_traceback.install(show_locals=True)
return _inner_run()
try:
status = _inner_run()
assert isinstance(status, int)
return status
except DiagnosticPipError as exc:
logger.error("%s", exc, extra={"rich": True})
logger.debug("Exception information:", exc_info=True)
return ERROR
except PreviousBuildDirError as exc:
logger.critical(str(exc))
logger.debug("Exception information:", exc_info=True)
return PREVIOUS_BUILD_DIR_ERROR
except (
InstallationError,
BadCommand,
NetworkConnectionError,
) as exc:
logger.critical(str(exc))
logger.debug("Exception information:", exc_info=True)
return ERROR
except CommandError as exc:
logger.critical("%s", exc)
logger.debug("Exception information:", exc_info=True)
return ERROR
except BrokenStdoutLoggingError:
# Bypass our logger and write any remaining messages to
# stderr because stdout no longer works.
print("ERROR: Pipe to stdout was broken", file=sys.stderr)
if level_number <= logging.DEBUG:
traceback.print_exc(file=sys.stderr)
return ERROR
except KeyboardInterrupt:
logger.critical("Operation cancelled by user")
logger.debug("Exception information:", exc_info=True)
return ERROR
except BaseException:
logger.critical("Exception:", exc_info=True)
return UNKNOWN_ERROR
def parse_args(self, args: List[str]) -> Tuple[Values, List[str]]:
# factored out for testability
return self.parser.parse_args(args)
def main(self, args: List[str]) -> int:
try:
with self.main_context():
return self._main(args)
finally:
logging.shutdown()
def _main(self, args: List[str]) -> int:
# We must initialize this before the tempdir manager, otherwise the
# configuration would not be accessible by the time we clean up the
# tempdir manager.
self.tempdir_registry = self.enter_context(tempdir_registry())
# Intentionally set as early as possible so globally-managed temporary
# directories are available to the rest of the code.
self.enter_context(global_tempdir_manager())
options, args = self.parse_args(args)
# Set verbosity so that it can be used elsewhere.
self.verbosity = options.verbose - options.quiet
reconfigure(no_color=options.no_color)
level_number = setup_logging(
verbosity=self.verbosity,
no_color=options.no_color,
user_log_file=options.log,
)
always_enabled_features = set(options.features_enabled) & set(
cmdoptions.ALWAYS_ENABLED_FEATURES
)
if always_enabled_features:
logger.warning(
"The following features are always enabled: %s. ",
", ".join(sorted(always_enabled_features)),
)
# Make sure that the --python argument isn't specified after the
# subcommand. We can tell, because if --python was specified,
# we should only reach this point if we're running in the created
# subprocess, which has the _PIP_RUNNING_IN_SUBPROCESS environment
# variable set.
if options.python and "_PIP_RUNNING_IN_SUBPROCESS" not in os.environ:
logger.critical(
"The --python option must be placed before the pip subcommand name"
)
sys.exit(ERROR)
# TODO: Try to get these passing down from the command?
# without resorting to os.environ to hold these.
# This also affects isolated builds and it should.
if options.no_input:
os.environ["PIP_NO_INPUT"] = "1"
if options.exists_action:
os.environ["PIP_EXISTS_ACTION"] = " ".join(options.exists_action)
if options.require_venv and not self.ignore_require_venv:
# If a venv is required check if it can really be found
if not running_under_virtualenv():
logger.critical("Could not find an activated virtualenv (required).")
sys.exit(VIRTUALENV_NOT_FOUND)
if options.cache_dir:
options.cache_dir = normalize_path(options.cache_dir)
if not check_path_owner(options.cache_dir):
logger.warning(
"The directory '%s' or its parent directory is not owned "
"or is not writable by the current user. The cache "
"has been disabled. Check the permissions and owner of "
"that directory. If executing pip with sudo, you should "
"use sudo's -H flag.",
options.cache_dir,
)
options.cache_dir = None
# 调用 _run_wrapper 执行命令逻辑。
return self._run_wrapper(level_number, options, args)