python 命令行(Cli)实现

背景

如何实现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)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值