动机、参考资料、涉及内容

动机

怎么写一个包, Github 开源项目贡献, CI/CD 等

涉及内容

  • setup.py, pip, pipx, poetry
  • requirements.txt, project.toml
  • mypy, pre-commit, isort, black, ruff, flake
  • python -m, sys.path, importlib
  • CI, CD

参考资料

Overview

备注: 尽量使用 python -m pip install xxx 而非 pip install xxx

术语释义 (python 3.8 文档, 术语解释并不过时):

要点如下:

  • pip: 装包的最底层工具之一, pip 本身不依赖于 setuptools, 但很多包在安装时会需要依赖 setuptools
  • setuptools: 最原始的工具是 distutils, setuptools 目前的实现也依赖于 distutils, setuptools 是非官方的事实标准, 在未来可能会成为标准库的一部分
  • egg: 已经弃用, 现在都是使用 whl 格式
  • easy_install: easy_install 是作为 setuptools 的一部分在 2004 年发布的, 现在已经完全弃用, 现在应该用 pip
  • build: python -m build 用来代替 python setup.py build
  • twine: 上传 .whl 及 .tar.gz 到 PyPI 的工具
  • hatch, pdm, poetry: 配置文件格式支持 pyproject.toml, 功能类似, 每一个都集合了 pip install, python -m build, twine 的功能, 甚至还能包含一些 CI/CD 的功能

包的发布与安装流程总览:

发布格式大体分为两类: 源码发布(Source Distribution, 简称 sdist, 也就是 .tar.gz 格式)与二进制格式发布 (binary distributions, 也称为 Wheels), 例如 pip==23.3.1 就包含两种发布格式: pip-23.3.1.tar.gzpip-23.3.1-py3-none-any.whl. 最佳实践发布源码格式以及一个或多个 whl 格式.

源码格式发布通常是checkout某个提交, 然后加上元信息文件 PKG-INFO, 以 pip-23.3.1.tar.gz 为例, 解压后文件目录与原始代码库的提交差距不大, 如下:

src/
  - pip/
  - pip.egg-info/  # 这个文件夹应该不是标准的做法, egg 已弃用
    - PKG-INFO
    - dependency_links.txt
    - not-zip-safe
    - SOURCES.txt
    - entry_points.txt
    - top_level.txt
PKG-INFO   # 这个文件的内容和 src/pip.egg-info/PKG-INFO 完全一致
...        # 其他文件都出现在原始代码库的相应提交里, 但原代码库里的一些文件例如 .pre-commit-config.yaml 文件不包含在 .tar.gz 文件内, 为什么会这样待研究, 猜测是和 pip 代码库本身的 CI/CD 工具设置有关

而二进制发布基本上等价于用户在安装时需要复制(到 site-packages 目录)的所有文件, 对于一个包的一个特定版本, PyPI 规定只能发布一个源码包, 但可以包含多个二进制包 (可以参考 opencv-python==4.8.1.78). 对于像这种包含 C 代码的项目, whl 文件里通常不包含 C 代码, 而只包含预编译好的 .so 文件. 而对于像 pip 这类纯 python 包, 其 whl 文件内只包含这种目录结构 (whl 文件实际上可以用 unzip 解压):

- pip/
- pip-23.3.1.dist-info/  # 注意这个和 sdist 里的文件夹名不一样, 内容也不太一样, 但都是文本文件
  - AUTHORS.txt
  - entry_points.txt
  - LICENSE.txt
  - METADATA # 这个文件等价于 sdist 中的 PKG-INFO 文件
  - RECORD   # 记录了 pip/ 文件夹中所有文件的哈希值
  - top_level.txt
  - WHEEL

备注: 有些包会出现类似这种情况, 例如: pip install GitPython 会安装这两个目录, git/GitPython-3.1.43.dist-info/, 注意前缀并不相同, 而 GitPython-3.1.43.dist-info/top_level.txt 里的内容是 git

这里先暂且不深入这两种格式的发布过程 Github -> .tar.gz/.whl -> PyPI. 我们先看使用者的视角, 使用者安装的过程通常是由 pip install 发起的, 这个过程大体上是:

  1. 先去尝试下载匹配的 .whl 文件, 然后基本上就是直接将 .whl 文件解压然后丢到 site-packages 文件夹下, 以上面的 pip==23.3.1 为例, 就是直接在 site-package 文件夹下增加了 pippip.egg-info 文件夹.
  2. 如果找不到匹配的 .whl 文件, 则下载源码格式发布的文件, 然后在本地将其打包为 .whl 格式, 然后将 .whl 格式文件进行安装

而本文的重点在于发布过程: Github -> CI/CD -> .tar.gz/.whl -> PyPI 或 Local Source Code -> .tar.gz/.whl -> PyPI

以下内容有如下主线

  • 许多工具实际上在底层都会用到虚拟环境, 特别地, 以 pipx 为例探索一下它地功能以及对虚拟环境的使用. 后续的 pre-commit 及 poetry 工具实际上也用到了虚拟环境.
  • poetry 怎么完成 Local Source Code -> .tar.gz/.whl -> PyPI: 在此之前我们先看下以前用 setuptools 是怎么做的, 而安装 poetry 建议使用 pipx, 我们也探索一下 pipx
  • 一些 CI/CD 过程中实际使用的底层工具: mypy, codespell, ruff 等
  • 本地开发时, 手工一个个执行这些 CI/CD 工具有些繁琐, 上面大多数 CI/CD 工具都属于代码质量检查, 因此完全可以卡在提交代码时自动触发, 自动修复.
  • GitHub 作为代码托管平台, 除了存放代码, 实际上还能实现 CI/CD. 所谓 CI/CD, 最直观的就是保证代码符合规范(否则不让代码合并), 在某些时机自动将代码打包并部署.

setuptools, setup.py

Is setup.py deprecated?, setuptools (包含 easy_install) 以及 setup.py 没有被弃用, 只是不要使用命令行用法, 例如 python setup.py install. setuptools 搭配 setup.py 仍然可以用于 build backend.

pyproject.toml 成为 setuptools 的标准之前, 为了使用 pip install . 或者 python setup.py install 安装一个包, 会涉及到多个 “配置文件”: setup.py, setup.cfg, MANIFEST.in. 它们的关系大概是这样:

  • 最早以前, 使用 setup.py
  • 后来由于 setup.py 被认为不安全, 因为 python setup.py install 会真的执行代码, 因此代码中可以包含有危险操作, 例如删除系统文件, 所以希望改为配置文件 setup.cfg, 然而在一些比较复杂的情况下, setup.cfg 不够灵活, 可能还是需要 setup.py, 因此对于 setup.py 的态度应该是能不用就尽量不用, 但需要用时就用
  • 再后来, CI/CD 工具越来越多, 每个工具都有一个特定名字的配置文件, 导致仓库的根目录总是会有一堆配置文件, 非常混乱, 因此大家约定都去读一个统一的文件 pyproject.toml, 而对于 setuptools 的来说, 就是把 setup.cfg 转为 pyproject.toml 的写法, 仍然是能只用 pyproject.toml 则用, 不得已还是可以继续用 setup.py, 而 setup.cfg 应该被弃用

一个例子: https://github.com/BuxianChen/happypig

toml

语法

直接参考自 https://toml.io/en/v1.0.0

# 这是注释格式, 在 toml 的术语里, a 称为 table (其实就是字典类型)
a.b = "a/b"    # 转换为 json: {"a": {"b": "a/b"}}
a.c = 1.23
a.d = true

# 在 Unix 上是: "This\nis\nxxx", 在Windows上是 "This\r\nis\r\nxxx"
a.e = """This
is
xxx
"""

a.f = 'C:\Users\nodejs\templates'  # 单引号括起来的字符串不需要转义
a."g.h" = 3    # 转换为 json: {"a": {"g.h": 3}}

# "no new line"
a.i = """no \
new line
"""

# array
integers = [1, 2, 3]
colors = [
    "red",
    "yellow",
    "green"
]
nested_arrays_of_ints = [ [ 1, 2 ], [3, 4, 5] ]
nested_mixed_array = [ [ 1, 2 ], ["a", "b", "c"] ]
string_array = [ "all", 'strings', """are the same""", '''type''' ]

# Mixed-type arrays are allowed
numbers = [0.1, 0.2, 0.5, 1, 2, 5 ]
contributors = [
  "Foo Bar <foo@example.com>",
  { name = "Baz Qux", email = "bazqux@example.com", url = "https://example.com/bazqux" }
]

# table (字典, 哈希表)
# 转换为 json {"table-1": {"key1": "some string", "key2": 123}}
[table-1]
key1 = "some string"
key2 = 123

# 转换为 json {"dog": {"tater.man": {"type": {"name": "pug"}}}}
[dog."tater.man"]
type.name = "pug"

# inline table
names = { first = "Tom", last = "Preston-Werner" }


# Arrays of table: peotry.lock 里常见
[[products]]
name = "Hammer"
sku = 738594937

[[products]]  # empty table within the array

[[products]]
name = "Nail"
sku = 284758393
color = "gray"

# 以上对应于 json 是:
# {"products": [
#     {"name": "Hammer", "sku": 738594937},
#     {},
#     {"name": "Nail", "sku": 284758393, "color": "gray"}
#   ]
# }

一个更高阶的用法:

[[fruits]]
name = "apple"

[fruits.physical]  # subtable
color = "red"
shape = "round"

[[fruits.varieties]]  # nested array of tables
name = "red delicious"

[[fruits.varieties]]
name = "granny smith"


[[fruits]]
name = "banana"

[[fruits.varieties]]
name = "plantain"

对应的 json 版本

{
  "fruits": [
    {
      "name": "apple",
      "physical": {
        "color": "red",
        "shape": "round"
      },
      "varieties": [
        { "name": "red delicious" },
        { "name": "granny smith" }
      ]
    },
    {
      "name": "banana",
      "varieties": [
        { "name": "plantain" }
      ]
    }
  ]
}

pyproject.toml

pyproject.toml 文件里的信息可能会被多个“工具”所读取, 但是它们只关注它们所需要的部分. 例如: 对于一份像这样的 pyproject.toml (以下为精简版, 完整内容来源于 langchain-community):

[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "langchain-community"
version = "0.0.34"
description = "Community contributed LangChain integrations."
repository = "https://github.com/langchain-ai/langchain"

[tool.poetry.dependencies]
python = ">=3.8.1,<4.0"
langchain-core = "^0.1.45"
SQLAlchemy = ">=1.4,<3"

[tool.poetry.group.test]
optional = true

[tool.poetry.group.test.dependencies]
pytest = "^7.3.0"
pytest-cov = "^4.1.0"

[tool.poetry.group.codespell]
optional = true

[tool.poetry.group.codespell.dependencies]
codespell = "^2.2.0"

[tool.ruff]
exclude = [
  "tests/examples/non-utf8-encoding.py",
  "tests/integration_tests/examples/non-utf8-encoding.py",
]

[tool.ruff.lint]
select = [
  "E",  # pycodestyle
  "F",  # pyflakes
  "I",  # isort
  "T201", # print
]

[tool.mypy]
ignore_missing_imports = "True"
disallow_untyped_defs = "True"
exclude = ["notebooks", "examples", "example_data"]

[tool.coverage.run]
omit = [
    "tests/*",
]

[tool.codespell]
skip = '.git,*.pdf,*.svg,*.pdf,*.yaml,*.ipynb,poetry.lock,*.min.js,*.css,package-lock.json,example_data,_dist,examples,*.trig'

应该这么看这份 pyproject.toml 文件: 像 build-systemtool.poetry 这两个 table 下的配置是用于给 poetry 命令使用的, 例如开发者手工使用 poetry install, poetry.build 命令时, poetry 会去读取这两个 table 的内容来执行命令, 更重要的是在 Github Action 里自动触发 poetry 命令; 而像 tool.ruff 这个 table 是在执行 ruff 命令时会被 ruff 所读取. 同理: mypy, codespell, coverage. 在这些工具不遵循 pyproject.toml “协议” 之前, 往往每个工具都需要有一个独立的配置文件, 例如:

  • setuptools: setup.py/setup.cfg, MANIFEST.in
  • mypy: .mypy.ini
  • tox: tox.ini

如今这些工具都支持了 pyproject.toml, 这样一来配置便可集中在这一个文件内, 这便是 pyproject.toml 的优势.

pipx

TL;DR

pipx 主要用于安装有 entrypoint 的 pypi 包, 使用 pipx 安装 pypi 包时, 它总会为 pypi 包新建一个虚拟环境. pipx 的作用类似于 apt, npm, 换句话说, pipx 将带有 entrypoint 的 pypi 包转换为了一个命令行命令, 由于 pipx 是为每个命令安装了独立的虚拟环境, 因此不会出现命令 A 需要依赖包 C==1.2, 而命令 B 需要依赖包 C==1.3, 造成安装时的意外升级与使用时的不一致.

In a way, it turns Python Package Index (PyPI) into a big app store for Python applications.

引用自

对比 pip install, 假设带有 entrypoint 的包 A, B 分别需要安装 C==1.2 和 C==1.3, 这样安装可能会造成不易察觉的问题

pip install A  # 假设 A 的 entrypoint 里包含 Aapp, 实际的例子: pip install torch 时会带有 torchrun 的 entrypoint
pip install B  # 假设 B 的 entrypoint 里包含 Bapp
pip list
# A 1.0.0
# B 1.0.0
# C 1.3
Aapp run ...  # 可能会出问题
Bapp run ...

pipx 到底怎么装? 官方推荐系统级别安装

sudo apt update
sudo apt install pipx  # /usr/bin/pipx
pipx ensurepath  # 修改 ~/.bashrc, ~/.bash_profile 等配置文件内容, 把 ~/.local/bin 添加进 PATH 变量
sudo pipx ensurepath --global  # Optional

用法

pipx install ipython
pipx reinstall ipython
pipx uninstall ipython
pipx ensurepath
pipx upgrade ipython
pipx inject ipython numpy  # 在 ipython 这个命令所在的虚拟环境中用 pip 安装 numpy
pipx run ipython

pipx 探幽

pipx 源码并不复杂, 主要是利用了 python 自带的 venvpip 包, 执行逻辑也可以参考 https://pipx.pypa.io/stable/how-pipx-works/, 也可以在执行 pipx 命令时加上 --verbose 选项.

目录结构

备注: pipx 使用的目录结构需要参考官方文档, 这里只是一个 pipx 版本的实现

import platformdirs
platformdirs.user_data_dir()/pipx/trash   # ~/.local/share/pipx/trash
platformdirs.user_data_dir()/pipx/shared  # ~/.local/share/pipx/shared
platformdirs.user_data_dir()/pipx/venvs   # ~/.local/share/pipx/venv
platformdirs.user_cache_dir()/pipx        # ~/.cache/pipx
platformdirs.user_log_dir()/pipx/log      # ~/.local/state/log/pipx/log

具体目录结构

~/.local/
  - bin/
    - poetry   # pipx 安装的可执行脚本
  - share/
    - pipx/  # ... 见下面
    - vitualenv/   # 这个似乎与 pipx 无关

使用 cd ~/.local/share/pipx && tree -L 5 打印目录 ~/.local/share/pipx 的目录结构:

conda create --name langchain python=3.10
conda activate langchain
pip install pipx
pipx install poetry
pip list | grep peotry  # 找不到 peotry, 因为 peotry 是在独立的虚拟环境装的
pipx ensurepath
# poetry config virtualenvs.prefer-active-python true  # 这个与 pipx 无关, 是 poetry 的配置
cd ~/.local/share/pipx && tree -L 5

~/.local/share/pipx 的目录结构如下

├── py
│   └── CACHEDIR.TAG
├── shared                  # 看起来似乎是用这里的 python 和 pip 为每个命令建的虚拟环境
│   ├── bin
│   │   ├── Activate.ps1
│   │   ├── activate
│   │   ├── activate.csh
│   │   ├── activate.fish
│   │   ├── pip             #!/home/buxian/.local/share/pipx/shared/bin/python
│   │   ├── pip3            #!/home/buxian/.local/share/pipx/shared/bin/python
│   │   ├── pip3.10         #!/home/buxian/.local/share/pipx/shared/bin/python
│   │   ├── python -> /home/buxian/anaconda3/envs/langchain/bin/python
│   │   ├── python3 -> python
│   │   └── python3.10 -> python
│   ├── include
│   ├── lib
│   │   └── python3.10
│   │       └── site-packages
│   │           ├── _distutils_hack
│   │           ├── distutils-precedence.pth
│   │           ├── pip
│   │           ├── pip-24.0.dist-info
│   │           ├── pkg_resources
│   │           ├── setuptools
│   │           └── setuptools-65.5.0.dist-info
│   ├── lib64 -> lib
│   └── pyvenv.cfg
└── venvs                  # 每个命令都是一个虚拟环境
    └── poetry
        ├── bin
        │   ├── Activate.ps1
        │   ├── activate
        │   ├── activate.csh
        │   ├── activate.fish
        │   ├── doesitcache
        │   ├── dul-receive-pack
        │   ├── dul-upload-pack
        │   ├── dulwich
        │   ├── keyring
        │   ├── normalizer
        │   ├── pkginfo
        │   ├── poetry
        │   ├── pyproject-build
        │   ├── python -> /home/buxian/anaconda3/envs/langchain/bin/python
        │   ├── python3 -> python
        │   ├── python3.10 -> python
        │   └── virtualenv
        ├── include
        ├── lib
        │   └── python3.10
        │       └── site-packages
        ├── lib64 -> lib
        ├── pipx_metadata.json
        └── pyvenv.cfg

pipx ensurepath

pipx ensurepath 的本质基本上就是 (pipx/commands/ensure_path.py:ensure_path):

import userpath
location_str = "~/.local/bin"
path_added = userpath.append(location_str, "pipx")

userpath 是一个 python 内置包, 会修改 shell configuration file, 例如: ~/.bashrc, ~/.bash_profile 等, 执行完后, 会添加类似这种内容:

# Created by `pipx` on 2024-04-12 07:34:33
export PATH="$PATH:/home/buxian/.local/bin"

其具体执行逻辑可以参考这个:

# userpath/interface.py:UnixInterface.put
for shell in self.shells:
    for file, contents in shell.config(location, front=front).items():
        try:
            if os.path.exists(file):
                with open(file, 'r', encoding='utf-8') as f:
                    lines = f.readlines()
            else:
                lines = []

            if any(contents in line for line in lines):
                continue

            lines.append(
                u'\n{} Created by `{}` on {}\n'.format(
                    shell.comment_starter, app_name, datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')
                )
            )
            lines.append(u'{}\n'.format(contents))

            with open(file, 'w', encoding='utf-8') as f:
                f.writelines(lines)
        except:
            continue

如果想知道到底修改了哪些 shell configuration file, 可以用这个办法验证:

from userpath.interface import Interface
interface = Interface(shells=None, all_shells=None, home=None)
for shell in interface.shells:
    for file, contents in shell.config("/home/buxian/.local/bin", front=True).items():
        print(file)
        print(contents)

输出:

/home/buxian/.bashrc
export PATH="/home/buxian/.local/bin:$PATH"
/home/buxian/.bash_profile
export PATH="/home/buxian/.local/bin:$PATH"

pipx list

pipx list 的输出也可以作为探索目录结构的参考

pipx list

输出

venvs are in /home/buxian/.local/share/pipx/venvs
apps are exposed on your $PATH at /home/buxian/.local/bin
manual pages are exposed at /home/buxian/.local/share/man
   package poetry 1.8.2, installed using Python 3.10.14
    - poetry

具体实现逻辑也很简单, 本质上就是 os.listdir("/home/buxian/.local/share/pipx/venvs")

# pipx/commands/list_packages:list_text
def list_text(venv_dirs: Collection[Path], include_injected: bool, venv_root_dir: str) -> VenvProblems:
    print(f"venvs are in {bold(venv_root_dir)}")
    print(f"apps are exposed on your $PATH at {bold(str(paths.ctx.bin_dir))}")
    print(f"manual pages are exposed at {bold(str(paths.ctx.man_dir))}")

    all_venv_problems = VenvProblems()
    for venv_dir in venv_dirs:
        # venv_dir = pathlib.PosixPath("/home/buxian/.local/share/pipx/venvs/poetry")
        # 疑问: include_injected 为 True 和 False 是什么意思
        package_summary, venv_problems = get_venv_summary(venv_dir, include_injected=include_injected)
        if venv_problems.any_():
            logger.warning(package_summary)
        else:
            print(package_summary)
        all_venv_problems.or_(venv_problems)

    return all_venv_problems

pipx install

pipx install 主要使用了:

  • 虚拟环境创建: /path/to/python -m venv ...
  • pip 安装必要的依赖包: /path/to/python -m pip install ...

具体可以参考这些源码

# pipx/venv.py
# Venv.create_venv
# Venv.install_package

def create_venv(self, venv_args: List[str], pip_args: List[str], override_shared: bool = False) -> None:
    """
    override_shared -- Override installing shared libraries to the pipx shared directory (default False)
    """
    with animate("creating virtual environment", self.do_animation):
        cmd = [self.python, "-m", "venv"]
        if not override_shared:
            cmd.append("--without-pip")
        venv_process = run_subprocess(cmd + venv_args + [str(self.root)], run_dir=str(self.root))
    subprocess_post_check(venv_process)

    shared_libs.create(verbose=self.verbose, pip_args=pip_args)
    if not override_shared:
        pipx_pth = get_site_packages(self.python_path) / PIPX_SHARED_PTH
        # write path pointing to the shared libs site-packages directory
        # example pipx_pth location:
        #   ~/.local/share/pipx/venvs/black/lib/python3.8/site-packages/pipx_shared.pth
        # example shared_libs.site_packages location:
        #   ~/.local/share/pipx/shared/lib/python3.6/site-packages
        #
        # https://docs.python.org/3/library/site.html
        # A path configuration file is a file whose name has the form 'name.pth'.
        # its contents are additional items (one per line) to be added to sys.path
        pipx_pth.write_text(f"{shared_libs.site_packages}\n", encoding="utf-8")

    self.pipx_metadata.venv_args = venv_args
    self.pipx_metadata.python_version = self.get_python_version()
    source_interpreter = shutil.which(self.python)
    if source_interpreter:
        self.pipx_metadata.source_interpreter = Path(source_interpreter)

def install_package(
    self,
    package_name: str,
    package_or_url: str,
    pip_args: List[str],
    include_dependencies: bool,
    include_apps: bool,
    is_main_package: bool,
    suffix: str = "",
) -> None:
    # package_name in package specifier can mismatch URL due to user error
    package_or_url = fix_package_name(package_or_url, package_name)

    # check syntax and clean up spec and pip_args
    (package_or_url, pip_args) = parse_specifier_for_install(package_or_url, pip_args)

    with animate(
        f"installing {full_package_description(package_name, package_or_url)}",
        self.do_animation,
    ):
        # do not use -q with `pip install` so subprocess_post_check_pip_errors
        #   has more information to analyze in case of failure.
        cmd = [
            str(self.python_path),
            "-m",
            "pip",
            "--no-input",
            "install",
            *pip_args,
            package_or_url,
        ]
        # no logging because any errors will be specially logged by
        #   subprocess_post_check_handle_pip_error()
        pip_process = run_subprocess(cmd, log_stdout=False, log_stderr=False, run_dir=str(self.root))
    subprocess_post_check_handle_pip_error(pip_process)
    if pip_process.returncode:
        raise PipxError(f"Error installing {full_package_description(package_name, package_or_url)}.")

    self._update_package_metadata(
        package_name=package_name,
        package_or_url=package_or_url,
        pip_args=pip_args,
        include_dependencies=include_dependencies,
        include_apps=include_apps,
        is_main_package=is_main_package,
        suffix=suffix,
    )

    # Verify package installed ok
    if self.package_metadata[package_name].package_version is None:
        raise PipxError(
            f"Unable to install "
            f"{full_package_description(package_name, package_or_url)}.\n"
            f"Check the name or spec for errors, and verify that it can "
            f"be installed with pip.",
            wrap_message=False,
        )

pipx run

pipx run 是创建一个虚拟环境在 cache 目录, 并运行这个虚拟环境里的 entrypoint, 例如: pipx run ipython, 会在 ~/.cache/pipx/0e4f05d9aae40dd 目录下安装虚拟环境, 但不会将可执行脚本 ipython 的软链接放在 ~/.local/bin 下, 而是位于原始的 ~/.cache/pipx/0e4f05d9aae40dd/bin/ 目录下.

from pipx import paths
print(paths.ctx.venv_cache)  # ~/.cache/pipx

pre-commit

TL;DR

安装

pip install pre-commit

用法

conda create --name precommit python=3.10
pip install pre-commit
git init
pre-commit install
git add .pre-commit-config.yaml  # 此例参考 https://github.com/open-mmlab/mmdeploy/blob/4bb9bc738c9008055fbc9347f46da70ee60fdad3/.pre-commit-config.yaml
git commit -m "add pre-commit config"
git add a.py
git commit -m "add a.py"   # 初次提交时会缓存 repo, 注意这些 repo 是直接 git clone 到缓存目录, 并且新建虚拟环境, 而不是 pip install 到当前环境

执行逻辑

pre-commit 依赖于 virtualenv, git, 在 git commit 时, 对于类型为 python 的 hook (代码仓库的 .pre-commit-hooks.yaml 里会写明), 如果 repo 写的是一个 github 地址而不是 local 的话, 那么会为每个 repovirtualenv 建立虚拟环境, 执行 hook 时会使用虚拟环境进行

pre-commit 探幽

本节以这个 .pre-commit-config.yaml 为例进行探索

repos:
  - repo: https://github.com/PyCQA/flake8  # 注意如果将 github 仓库地址改为 local, 那么就不会缓存至目录并创建独立的虚拟环境
    rev: 4.0.1
    hooks:
      - id: flake8  # 注意这个 id 不能随意写, 它与 https://github.com/PyCQA/flake8 的 `.pre-commit-hooks.yaml` 里的一个 hook id 对应.
        args: ["--exclude=*/client/inference_pb2.py, \
                */client/inference_pb2_grpc.py, \
                tools/package_tools/packaging/setup.py"]
  - repo: https://github.com/PyCQA/isort
    rev: 5.11.5
    hooks:
      - id: isort
  - repo: https://github.com/pre-commit/mirrors-yapf
    rev: v0.32.0
    hooks:
      - id: yapf
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.2.0
    hooks:
      - id: trailing-whitespace
      - id: check-yaml
      - id: end-of-file-fixer
      - id: requirements-txt-fixer
      - id: double-quote-string-fixer
      - id: check-merge-conflict
      - id: fix-encoding-pragma
        args: ["--remove"]
      - id: mixed-line-ending
        args: ["--fix=lf"]
  - repo: https://github.com/executablebooks/mdformat
    rev: 0.7.9
    hooks:
      - id: mdformat
        args: ["--number", "--disable-escape", "link-enclosure"]
        additional_dependencies:
          - mdformat-openmmlab
          - mdformat_frontmatter
          - linkify-it-py
  - repo: https://github.com/codespell-project/codespell
    rev: v2.1.0
    hooks:
      - id: codespell
        args: ["--skip=third_party/*,*.ipynb,*.proto"]

  - repo: https://github.com/myint/docformatter
    rev: v1.4
    hooks:
      - id: docformatter
        args: ["--in-place", "--wrap-descriptions", "79"]

pre-commit install

执行 pre-commit install 的本质是写入 .git/hooks/pre-commit 文件, 文件内容类似如下:

#!/usr/bin/env bash
# File generated by pre-commit: https://pre-commit.com
# ID: 138fd403232d2ddd5efb44317e38bf03

# start templated
INSTALL_PYTHON=/home/buxian/anaconda3/envs/precommit/bin/python
ARGS=(hook-impl --config=.pre-commit-config.yaml --hook-type=pre-commit)
# end templated

HERE="$(cd "$(dirname "$0")" && pwd)"
ARGS+=(--hook-dir "$HERE" -- "$@")

if [ -x "$INSTALL_PYTHON" ]; then
    exec "$INSTALL_PYTHON" -mpre_commit "${ARGS[@]}"
elif command -v pre-commit > /dev/null; then
    exec pre-commit "${ARGS[@]}"
else
    echo '`pre-commit` not found.  Did you forget to activate your virtualenv?' 1>&2
    exit 1
fi

具体的关键逻辑如下:

# pre_commit/commands/install_uninstall.py
def resource_text(filename: str) -> str:
    files = importlib.resources.files('pre_commit.resources')
    return files.joinpath(filename).read_text()

# pre_commit/resources/hook-impl 文件中包含上面 .git/hooks/pre-commit 中的内容模板
# 实际写入时会替换掉 `# start templated` 和 `# end templated` 之间的内容
# 替换逻辑详见: _install_hook_script
def _install_hook_script(...):
    ...

git commit & pre-commit 缓存文件夹

第一次执行 git commit -m "add a.py" 时的输出为:

[INFO] Initializing environment for https://github.com/PyCQA/flake8.
[INFO] Initializing environment for https://github.com/PyCQA/isort.
[INFO] Initializing environment for https://github.com/pre-commit/mirrors-yapf.
[INFO] Initializing environment for https://github.com/pre-commit/pre-commit-hooks.
[INFO] Initializing environment for https://github.com/executablebooks/mdformat.
[INFO] Initializing environment for https://github.com/executablebooks/mdformat:mdformat-openmmlab,mdformat_frontmatter,linkify-it-py.
[INFO] Initializing environment for https://github.com/codespell-project/codespell.
[INFO] Initializing environment for https://github.com/myint/docformatter.
[INFO] Installing environment for https://github.com/PyCQA/flake8.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
[INFO] Installing environment for https://github.com/PyCQA/isort.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
[INFO] Installing environment for https://github.com/pre-commit/mirrors-yapf.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
[INFO] Installing environment for https://github.com/pre-commit/pre-commit-hooks.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
[INFO] Installing environment for https://github.com/executablebooks/mdformat.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
[INFO] Installing environment for https://github.com/codespell-project/codespell.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
[INFO] Installing environment for https://github.com/myint/docformatter.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
flake8...................................................................Failed
- hook id: flake8
- exit code: 1

t.py:3:1: E302 expected 2 blank lines, found 1
t.py:6:1: E305 expected 2 blank lines after class or function definition, found 1
t.py:9:1: E402 module level import not at top of file
t.py:18:6: W292 no newline at end of file

isort....................................................................Failed
- hook id: isort
- files were modified by this hook

Fixing /home/buxian/wsl2_test/test_code/test_precommit/t.py

yapf.....................................................................Failed
- hook id: yapf
- files were modified by this hook
trim trailing whitespace.................................................Passed
check yaml...........................................(no files to check)Skipped
fix end of files.........................................................Passed
fix requirements.txt.................................(no files to check)Skipped
fix double quoted strings................................................Failed
- hook id: double-quote-string-fixer
- exit code: 1
- files were modified by this hook

Fixing strings in t.py

check for merge conflicts................................................Passed
fix python encoding pragma...............................................Passed
mixed line ending........................................................Passed
mdformat.............................................(no files to check)Skipped
codespell................................................................Passed
docformatter.............................................................Passed

首先注意观察类似这种输出:

[INFO] Installing environment for https://github.com/PyCQA/flake8.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...

这个过程做的是将依赖的 repo 使用 git clone 至缓存文件夹 (默认位于 ~/.cache/pre-commit), 这里文件夹名是通过 tempfile.mkdtemp 生成的

# pre_commit/store.py:_get_default_directory
import os
def _get_default_directory() -> str:
    ret = os.environ.get('PRE_COMMIT_HOME') or os.path.join(
        os.environ.get('XDG_CACHE_HOME') or os.path.expanduser('~/.cache'),
        'pre-commit',
    )
    return os.path.realpath(ret)

# pre_commit/store.py:Store._new_repo
# 默认情况下: self.directory = _get_default_directory()
directory = tempfile.mkdtemp(prefix='repo', dir=self.directory)

缓存文件夹目录如下:

.
├── README
├── .lock            # 估计只是一个 filelock 文件
├── db.db            # sqlite3 数据库文件
├── repo3ryn4y_0/    # 对应 https://github.com/codespell-project/codespell
├── repoch_61y63/    # 对应 https://github.com/PyCQA/isort
├── repohkjm0j33/    # 对应 https://github.com/pre-commit/mirrors-yapf
├── repomk6u13hi/    # 对应 https://github.com/pre-commit/pre-commit-hooks
├── reporpoqbcsp/    # 对应 https://github.com/executablebooks/mdformat     # commit-id: 0c86e45
├── repots3lczbh/    # 对应 https://github.com/PyCQA/flake8
├── repoxea00f_0/    # 对应 https://github.com/executablebooks/mdformat     # commit-id: 99099d5
└── repoylmsb8fs/    # 对应 https://github.com/myint/docformatter

可以打印出 db.db 的 Schema 及数据信息, 如下:

==================================================
Schema for table: repos
repo TEXT, ref TEXT, path TEXT, 

Data for table: repos
('https://github.com/PyCQA/flake8', '4.0.1', '/home/buxian/.cache/pre-commit/repots3lczbh')
('https://github.com/PyCQA/isort', '5.11.5', '/home/buxian/.cache/pre-commit/repoch_61y63')
('https://github.com/pre-commit/mirrors-yapf', 'v0.32.0', '/home/buxian/.cache/pre-commit/repohkjm0j33')
('https://github.com/pre-commit/pre-commit-hooks', 'v4.2.0', '/home/buxian/.cache/pre-commit/repomk6u13hi')
('https://github.com/executablebooks/mdformat', '0.7.9', '/home/buxian/.cache/pre-commit/reporpoqbcsp')
('https://github.com/executablebooks/mdformat:mdformat-openmmlab,mdformat_frontmatter,linkify-it-py', '0.7.9', '/home/buxian/.cache/pre-commit/repoxea00f_0')
('https://github.com/codespell-project/codespell', 'v2.1.0', '/home/buxian/.cache/pre-commit/repo3ryn4y_0')
('https://github.com/myint/docformatter', 'v1.4', '/home/buxian/.cache/pre-commit/repoylmsb8fs')


==================================================
Schema for table: configs
path TEXT, 

Data for table: configs
('/home/buxian/wsl2_test/test_code/test_precommit/.pre-commit-config.yaml',)

接下来再看 git 的 pre-commit hook 实际执行的内容 (.git/hooks/pre-commit):

python -mpre-commit hook-impl --config=.pre-commit-config.yaml --hook-type=pre-commit --hook-dir /home/buxian/wsl2_test/test_code/test_precommit/.git/hooks --

此处跳过一些细节, 本质上执行的是 pre_commit/languages/python.py 的相关内容 (此例中所有的 hook 的 language 都是 python)

# pre_commit/commands/run.py:_run_single_hook
language = languages[hook.language]
with language.in_env(hook.prefix, hook.language_version):
    retcode, out = language.run_hook(
        hook.prefix,
        hook.entry,
        hook.args,
        filenames,
        is_local=hook.src == 'local',
        require_serial=hook.require_serial,
        color=use_color,
    )

hook.language 是由 repo 的 .pre-commit-hooks.yaml 决定的, 例如: https://github.com/PyCQA/flake8/.pre-commit-hooks.yaml 文件内容是

-   id: flake8
    name: flake8
    description: '`flake8` is a command-line utility for enforcing style consistency across Python projects.'
    entry: flake8
    language: python
    types: [python]
    require_serial: true

执行的实际方式是先创建虚拟环境(如果没有创建的话), 然后使用这个虚拟环境运行 hook

# STEP 1: pre_commit/languages/python.py:install_environment
# 对于 language=python 类型的 hook, 首先在 /home/buxian/.cache/pre-commit/repots3lczbh 底下用 virtualenv 安装虚拟环境, 例如安装在
# /home/buxian/.cache/pre-commit/repots3lczbh/py_env-python3.10

envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version)
venv_cmd = [sys.executable, '-mvirtualenv', envdir]
python = norm_version(version)
if python is not None:
    venv_cmd.extend(('-p', python))
install_cmd = ('python', '-mpip', 'install', '.', *additional_dependencies)
proc = subprocess.Popen(install_cmd, **kwargs)

# STEP 2: pre_commit/languages/python.py:in_env, run_hook
# 添加 PATH 环境变量 (通过 contextmanager 来实现), 然后执行
os.environ["PATH"] = "/home/buxian/.cache/pre-commit/repoylmsb8fs/py_env-python3.10/bin" + ":" + os.environ["PATH"]
cmd = ['flake8', '--exclude=*/client/inference_pb2.py, */client/inference_pb2_grpc.py, tools/package_tools/packaging/setup.py']
subprocess.Popen(cmd, **kwargs)

poetry

poetry 命令

  • poetry.lock 文件推荐不应该被 ignore, 而应该交由 git 管理.
  • poetry 现在的 installer 貌似已经不依赖于 pip 了. blog.
  • poetry 可以用于包含 C++ 代码的项目, 但官方文档似乎没有过多介绍
  • 可以在 poetry 命令里加上 -vvv 选项, 观察其行为, 例如: poetry update -vvv, poetry config --list -vvv
  • poetry 支持两种模式: package mode 和 no package mode, 代表了两种使用 poetry 的目标, 前者是开发一个 python 包, 后者是只是写一些 python 脚本, 但希望用 peotry 进行包的依赖管理.
  • poetry 中 group 只是逻辑上的划分, 不同的 group 必须相互兼容, 即不能出现一个 group 中的包与另一个 group 中的包发生冲突

完整命令介绍参考: https://python-poetry.org/docs/cli

# 初始化: 从零开始, 以下命令会生成样板项目目录
poetry new poetry-demo        # flat layout
poetry new --src poetry-demo  # src layout
# 关于 flat layout 和 src layout 可以参考这个讨论
# https://packaging.python.org/en/latest/discussions/src-layout-vs-flat-layout/

# 初始化: 对于已经已经存在的项目, 以下命令会用命令行交互式的方式生成 pyproject.toml
poetry init

# 添加包: 往 pyproject.toml 文件中添加包, 并且安装包, 并且将安装的包写入 peotry.lock 文件中
poetry add numpy                 # 把 numpy 放在 [tool.poetry.dependencies]
poetry add pytest --group test   # 功能与上面相同, 但把 pytest 放在组里 [tool.poetry.group.test.dependencies]

# 删除包: 往 pyproject.toml 文件中移除包, 并且移除包, 并且更新 peotry.lock 文件
poetry remove numpy                 # 把 numpy 从 [tool.poetry.dependencies] 移除
poetry remove pytest --group test   # 从 group 里移除

# 更新包: 更新包, 并且修改 pyproject.toml 及 poetry.lock 文件
poetry update numpy
poetry update

# 如果存在 poetry.lock 文件, 则严格按照 poetry.lock 安装相应的包, 否则按照 pyproject.toml 文件安装, 并生成 poetry.lock 文件
poetry install
# poerty.toml 文件里的某些 group 设置了 optional = true 的, 需要手工指定 with 进行安装
poetry install --with test,docs
# 完全按照 poetry.lock 文件来安装/更新包, 并在当前环境中移除所有不在 poetry.lock/pyproject.toml 中记录的包
poetry install --sync  # !!! 请确保自己知道在做什么, 会删除不在 poetry.lock/pyproject.toml 中记录的包

# 展示 poetry 管理的包, 注意: 如果混用 pip 和 poetry 可能会造成混乱, 例如单独用 pip 额外安装的包不会显示在 poetry show 的输出里
poetry show

# 打包为源码格式 .tar.gz 和二进制格式 .whl
poetry build

# 发布至 PyPI
poetry publish
poetry publish --build  # build + publish

# 运行脚本, 基本上就是加上前缀 poetry run. 注意当 poetry 实际创建了新的虚拟环境时, 前缀是必须的, 但是如果 poetry 没有创建虚拟环境时, 前缀不必要
poetry run python xx.py
poetry run pytest
poetry run package-entrypoint  # 包的 entrypoint

# 进入虚拟环境 (创建子 shell, 输入 exit 退出)
poetry shell

# 转换为 requirements.txt
poetry export -f requirements.txt --output requirements.txt

pyproject.toml

参考前文

PyPI

最原始的打包发布流程一般是

python -m build
python -m twine upload --repository testpypi dist/*

build 包是所谓的 build frontend, 其实 pip 也属于 build frontend. 它们会触发 build backend (也就是 pyproject.toml) 里的 build-system 定义的:

# setuptools
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

# poetry
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

twine 包用于上传至 PyPI

而 poetry 包装了所有的这些过程 (但其底层不一定会使用 build, pip, twine 这些工具)

Github Action

基本的架子是: .github/workflows/publish-to-pypi.yaml

name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI
on: push  # 触发时机
jobs:
  build:   # 每个 job 需要在一个 docker 容器内完成, 每个 job 又可以继续拆解为多个 step, 注意是否拆解为 step 纯粹是为了逻辑上更直观, 以及监控 CI/CD 日志时更好定位问题
    name: Build distribution 📦
    runs-on: ubuntu-latest  # 执行下述 steps 的基础 docker 镜像
    steps:
    - uses: actions/checkout@v4  # step 的写法1: 使用现成的 CI/CD 工具, 猜测实际上也是一段 shell 脚本
    - name: Set up Python
      uses: actions/setup-python@v4
      with:                      # 使用现成的 CI/CD 工具, 带配置参数
        python-version: "3.x"
    - name: Install pipx and poetry
      run: python3 -m pip install pipx --user && pipx install poetry  # step 的写法 2: 直接运行命令
    - name: Build a binary wheel and a source tarball
      run: poetry build
    - name: Store the distribution packages
      uses: actions/upload-artifact@v3
      with:
        name: python-package-distributions
        path: dist/
  publish-to-pypi:  # 第二个 job
    name: >-
      Publish Python 🐍 distribution 📦 to PyPI
    if: startsWith(github.ref, 'refs/tags/')  # 条件触发: 只有push的是tag时才触发
    needs:
    - build  # job 间的依赖关系: 必须等前一个 job 完成才开始
    runs-on: ubuntu-latest
    environment:
      name: pypi
      url: https://pypi.org/p/happycow 
    permissions:
      id-token: write
    steps:
    - name: Download all the dists
      uses: actions/download-artifact@v3
      with:
        name: python-package-distributions
        path: dist/
    - name: Publish distribution 📦 to PyPI
      uses: pypa/gh-action-pypi-publish@release/v1

附录

ensurepip

TL;DR: ensurepip 是 python 自带的包, 包里面附带了一个 setuptools 和 pip 的 whl 文件, 如果不小心把 pip 包损坏了 (例如升级 pip 时, 把原始的 pip 卸载了, 但是安装新 pip 时又出现权限问题; 或者 debug 时手动乱改了 pip 包的源文件), 可以通过 ensurepip 利用它自带的 whl 文件恢复一个较低版本的 pip, 然后再进行 pip 升级即可.

如果遇到特殊情况, 可以按照如下命令安装 pip. (参考)

python -m ensurepip --upgrade

具体的执行逻辑(仅做示意,不同Python版本的实现可能不同)大致是: 首先在 /path/to/ensurepip 底下有目录结构 (可以通过 import ensurepip; print(ensurepip.__file__) 找到这个路径):

├── __init__.py
├── __main__.py
├── _bundled
│   ├── __init__.py
│   ├── pip-23.0.1-py3-none-any.whl
│   └── setuptools-65.5.0-py3-none-any.whl
└── _uninstall.py

python -m ensurepip 的逻辑是将这里的两个 pip*.whlsetuptools*.whl 文件保存到一个临时目录, 然后按以下方式安装:

# 将 *.whl 复制进 tmpdir 内 
additional_paths = 
args = ["install", "--no-cache-dir", "--no-index", "--find-links", tmpdir] + 
    # ["--root", root]  # 安装目录 一般不会指定
    # ["--upgrade"]  # 如果指定 --upgrade 的话
    # ["--user"]  # 如果指定 --user 的话
    # ["-vvv"]   # 可以指定 -v 1, -v 2, -v 3, 最多到 3
code = f"""
import runpy
import sys
sys.path = {additional_paths or []} + sys.path
sys.argv[1:] = {args}
runpy.run_module("pip", run_name="__main__", alter_sys=True)
"""

# runpy.run_module 等价于 python -m
# 有趣的是可以在没有安装pip的时候, 但pip*.whl位于--find-links目录时, 就可以 python -m pip install ...

cmd = [
    sys.executable,  # 这个实际上就是当前 python 的绝对路径
    '-W',
    'ignore::DeprecationWarning',
    '-c',
    code,
]
if sys.flags.isolated:
    # run code in isolated mode if currently running isolated
    cmd.insert(1, '-I')
subprocess.run(cmd, check=True)

PyPI JSON API

pip, poetry 等工具在运行时会使用到 PyPI JSON API, 用于解析依赖 (但是这个 API 里似乎没有依赖包的信息, API 返回的信息很大程度上是代码打包时, 如果是二进制打包, 是 METADATA 文件内容, 或者是源码打包, PKG-INFO 文件内容)? 然而并不是所有 PyPI 的包写的 metadata 信息都完善, 因此有些时候对于 pip 或 poetry 来说确认依赖关系只能先下载下来再做验证

  • pip 相关的代码似乎在 pip/_vendor/locations.py:PyPIJSONLocator
  • poetry 的一个 FAQ: https://python-poetry.org/docs/faq/, poetry 会缓存尝试过的包的 metadata 信息, 位于 ~/.cache/pypoetry 目录下, 可以自行探索

API 参考文档: https://warehouse.pypa.io/api-reference/json.html

GET /pypi/<project_name>/json
GET /pypi/<project_name>/<version>/json

https://pypi.org/pypi/pip/json
https://pypi.org/pypi/pip/23.3.1/json

pip: vendoring

在过去 pip 的内部实现需要自己造很多轮子, 例如网络请求. 这是为了避免有第三方依赖项, 导致 pip 本身出现问题, 例如: 假设 pip 将 requests==2.31.0 作为依赖项, 如果使用者用 pip 将 requests 进行了降级或者升级, 这可能会导致下次使用 pip 时出现问题, 然而, 这个时候没有很好的解决问题的方式了. 因此 pip 只好极力避免依赖项, 而是自己造所有的轮子. 后来, pip 采用了另一种策略, 例如假设 pip 需要使用 requests==2.31.0 的功能, pip 就将 requests==2.31.0 的代码全部拷贝进自己本身的代码里, 并做适当的小修改. 这样一来, 就能避免造轮子, 而且也不会引发前面的问题了: pip 升级或降级 requests 不会影响它内部拷贝的那一份 requests==2.31.0. 这种拷贝的做法就被称为 vendoring.

一些实现细节:

  • vendoring 只为 pip 服务, 不是通用工具, pip 代码库本身的 pyproject.toml 文件里包含 [tool.vendoring] 的配置项
  • pip/_vendor/vendor.txt 记录了复制的三方包代码的版本号

备注: 假设你的环境底下甚至没有 pip, setuptools 等, 实际上也可以去下载 whl 文件, 将其解压至 site-packages 目录进行手工安装

杂录

pip 可以直接安装 Git 仓库, 参考: https://pip.pypa.io/en/stable/topics/vcs-support/

pip install git+https://github.com/username/MyProject.git@master   # 通常是分支名
pip install git+https://github.com/username/MyProject.git@v1.0     # 通常是 tag 名
pip install git+https://github.com/username/MyProject.git@da39a3ee5e6b4b0d3255bfef95601890afd80709  # 通常是 commit-id
pip install git+https://github.com/username/MyProject.git@refs/pull/123/head  # 通常是一个 PR, 其中 123 是 PR 的编号
pip install git+https://github.com/username/MyProject.git@master#subdirectory=pkg_dir   # 分支名加子目录

pyproject.toml 文件中设置了 poetry-core 作为 build-backend 时, 使用源码安装譬如说 pip install -e . 时, 如果加上 -vvv 选项, 可以观察到 pip 会先下载 poetry-core, 然后再进行包的安装. 注意无论当前环境是否有安装 poetry-core, pip 都会去安装 poetry-core, 这个 poetry-core 会是一个临时环境, 仅存在于内存(或者是临时目录, 有待确认).