通过 PDM 和 GitHub Actions 在 PyPI 上自动化发布你的 Python 包吧

Category 碎碎念

最近换用 PDM 作为主要的 Python 环境管理工具,虽然使用细节上还不太熟悉,但终究是搭配着 Anaconda 用起来了。PDM 是一款轻巧的工具,但它却涵盖了 Python 开发中的各种场景,例如自动生成项目的 pyproject.toml,自动解决 package 的版本依赖问题,就算我还未使用很久,也已经为之着迷了。

n

PDM 提供的各种功能

很值得注意的是 PDM 提供了 build 的功能,能够将源码构建为 Python package 并发布到 PyPI,不再需要其他繁琐的工具,我也抱着极大的兴趣探索了结合 PDM 与 GitHub Actions 发布 Python 包的方法。PDM 提供了多平台的多种安装方法,读者可以根据自己的要求安装,本文在 Windows 上使用 pdm==2.15.0 作为演示。

初始化项目

安装好 PDM 后,新建项目文件夹,用终端进入文件夹并执行 pdm init 初始化项目,在这里我使用的是 PowerShell。PDM 会通过几个问答来指引用户初始化项目:

> pdm init
Creating a pyproject.toml for PDM...
Please enter the Python interpreter to use
 0. cpython@3.9 (F:\Miniconda\python.EXE)
 1. cpython@3.12 (C:\Users\Leo\scoop\apps\python\current\python.exe)
 2. cpython@3.12 (C:\Users\Leo\scoop\shims\python3.exe)
 3. cpython@3.9 (D:\Python39\python.exe)
 4. cpython@3.7 (D:\Python37\python37.exe)
 5. cpython@3.7 (D:\Python37\python.exe)
Please select (0): 3
Virtualenv is created successfully at D:\Code\gh-action-demo\.venv
Project name (gh-action-demo):leo-gh-action-demo
Project version (0.1.0):
Do you want to build this project for distribution(such as wheel)?
If yes, it will be installed by default when running `pdm install`. [y/n] (n): y
Project description ():
Which build backend to use?
0. pdm-backend
1. setuptools
2. flit-core
3. hatchling
Please select (0):
License(SPDX name) (MIT):
Author name (Leo):
Author email (im.yczeng@foxmail.com):
Python requires('*' to allow any) (>=3.9):
Project is initialized successfully

大部分设置项可以直接回车,选用括号中的默认值即可,比较重要的两项是

  • PDM 会自动搜寻设备上的 Python 解释器,需要根据需求选择项目的 Python 版本,如果输出中没有列出目标的 Python,就要检查 PDM 还是 Python 没有正确安装;
  • 选择项目 build backend 为 pdm-backend,这一点没有特别的原因,只是因为我们在用 PDM 所以就都用 PDM 的 toolkit 好啦。

以上步骤都完成后,项目文件夹的结构会是

D:\CODE\GH-ACTION-DEMO
│  .gitignore
│  .pdm-python
│  pyproject.toml
│  README.md
├─.venv
├─src
│  └─gh_action_demo
└─tests

.venv 是项目的虚拟环境,src 是项目源码目录,tests 用于存放测试文件。pyproject.toml 保存了项目的配置项,也包括 PDM 初始化时的配置,里面的内容也可以根据开发的需求手动修改:

[project]
name = "leo-gh-action-demo"
version = "0.1.0"
description = "Default template for PDM package"
authors = [
    {name = "Leo", email = "im.yczeng@foxmail.com"},
]
dependencies = []
requires-python = ">=3.9"
readme = "README.md"
license = {text = "MIT"}

[build-system]
requires = ["pdm-backend"]
build-backend = "pdm.backend"

[tool.pdm]
distribution = true

从零开始的开发

如果在项目开始之前就选用了 PDM 管理环境那当然最好不过,直接在 src/<project_name> 目录中写入第一行代码吧。完成后执行 pdm build 开始构建,这个过程中 PDM 会生成以下内容:

D:\CODE\GH-ACTION-DEMO
├─.pdm-build
│  │  .gitignore
│  └─gh_action_demo-0.1.0.dist-info
│          METADATA
│          WHEEL
├─dist
│      gh_action_demo-0.1.0-py3-none-any.whl
│      gh_action_demo-0.1.0.tar.gz
...

.pdm-build 是构建过程产生的中间文件,dist 目录中就是可以用于正式发布的 Python package 压缩包了。让我们查看一下 PDM 帮助打包了项目中的哪些内容:

> tar -tf .\dist\gh_action_demo-0.1.0.tar.gz
gh_action_demo-0.1.0/README.md
gh_action_demo-0.1.0/pyproject.toml
gh_action_demo-0.1.0/src/gh_action_demo/__init__.py
gh_action_demo-0.1.0/src/gh_action_demo/main.py
gh_action_demo-0.1.0/tests/__init__.py
gh_action_demo-0.1.0/PKG-INFO

可以看到压缩包是很规范的结构,唯一不太妙的地方在于 PDM 将 tests 目录也打包在内,测试文件中可能会包含体积比较大的数据文件,这对于使用 package 的用户是没有必要的。因此可以在 pyproject.toml 中添加以下的配置项,手动指定不纳入 package 的目录或文件:

[tool.pdm.build]
excludes = ["tests/"]

再次运行 pdm build,可以发现新构建的压缩包已经把 tests/ 排除在外了。

从已有项目迁移

很多情况下,我们并不是一开始就使用 PDM 的项目结构,例如我的项目也是开发了一半时才换用 PDM 管理。如果项目比较简单,可以调整项目的结构,在 src/ 中组织源码,那么构建的方法就和前文一致了。如果项目结构比较复杂或是有特殊需求而不能将源码调整至 src/ 中,那么就需要在 pyproject.toml 中手动设置打包的源码目录,例如:

[tool.pdm.build]
includes = ["main/", "plugins/"]
excludes = ["tests/"]

运行 pdm build 即可将 main/plugins/ 目录打包。

准备发布到 PyPI

我们在本地完成了 package 的构建,dist 目录中生成的 *.tar.gz*.whl 就已经可以借助各种工具发布到 PyPI。不过更优的方法是完成开发后直接将代码推送至 GitHub 仓库,借助 GitHub Actions 的功能在云上完成构建并自动发布。

不论何种方法,在发布之前都要先注册 PyPI 和 TestPyPI 的账号。PyPI 是安装 Python package 的常用仓库,不用作多余的介绍。TestPyPI 是 PyPI 的测试仓库,主要用于测试发布、安装 package 是否正常,可视作在 PyPI 正式发布前的「预览版」。二者的流程完全相同,唯一不同在于数据、账号并不通用,必须分别注册。二者注册过程中包括开启二次验证等操作,步骤稍有些复杂,但很容易能够查找到详尽的教程,在此不再赘述。

 Note 因为本文用于演示发布的流程,后文主要以 TestPyPI 作为示例,设置项中与 PyPI 的细微区别以提示的方式给出。

登录 TestPyPI 账号后进入发布页面,我们选用 Trusted Publisher 的方式发布。

n

拖动页面至最底部,在「Add a new pending publisher」中选择「GitHub」,按要求填入 Publisher 的信息:

n

必填项包括以下 4 项:

  • PyPI Project Name:在 TestPyPI(PyPI)中索引的 package 名称,必须与 pyproject.toml 中的 name 一致;
  • Owner:GitHub 用户名;
  • Repository name:GitHub 仓库名;
  • Workflow name:GitHub Actions 文件名,GitHub Actions 通过 YAML 文件配置,所以后缀应为 .yml.yaml

完成后选择「Add」,就能看到准备中的 Publisher:

n

添加 GitHub Actions

在 GitHub 中创建仓库,注意仓库名应与 Publisher 中设置的仓库名完全一致。在推送代码之前,我们还需要在项目中添加 GitHub Actions 的配置。

在项目文件夹中添加 .github/workflows/ 文件夹,在其中新建一个 YAML 文件,注意文件名应与 Publisher 中设置的「Workflow name」一致。在 YAML 文件中添加配置,对 PDM 官方文档提供的模版稍作修改并逐行添加了注释:

# 显示在 GitHub UI 中的 workflow 名称
name: Publish Python distributions to TestPyPI

# 触发条件,在任何分支有 push 时触发该 workflow
on:
  push

# workflow 由若干个 job 组成,job 之间并行运行,此处只有 testpypi-publish 1 个 job
jobs:
  # job 标识名称
  testpypi-publish:
    # 显示在 GitHub UI 中的 job 名称
    name: upload release to TestPyPI
    # 运行 job 的计算机平台
    runs-on: ubuntu-latest
    # PDM 所需的权限
    permissions:
      # This permission is needed for private repositories.
      contents: read
      # IMPORTANT: this permission is mandatory for trusted publishing
      id-token: write
    # 每个 job 由若干 step 组成
    steps:
      # PDM 所需的环境配置
      - uses: actions/checkout@v3

      - uses: pdm-project/setup-pdm@v3

      # 运行命令行程序,通过 PDM 发布到 TestPyPI
      # `pdm publish` 命令先完成 `pdm build` 再将 `dist/` 中的内容发布
      - name: Publish package distributions to TestPyPI
        run: pdm publish --repository testpypi

 Note 若要将 package 发布到 PyPI 只需要将 workflow 中的 pdm publish --repository testpypi 改为 pdm publish

完成以上步骤后,再检查一下项目的文件结构,应当为

D:\CODE\GH-ACTION-DEMO
│  .gitignore
│  .pdm-python
│  pyproject.toml
│  README.md
├─.github
│  └─workflows
│          testpypi_publish.yml
├─.pdm-build
├─.venv
├─dist
├─src
│  └─gh_action_demo
│          main.py
└─tests

确认无误后就可以把项目推送到 GitHub 仓库了。

 Note 需要推送到 GitHub 仓库的仅有 .gitignore pyproject.toml README.md .github/ src/ tests/ 这几个文件与目录,.pdm-build/ .venv dist 都不应当上传,很方便的是 PDM 生成的 .gitignore 已经自动排除了这些留在本地的项目。

如果上述配置全部正确,打开 GitHub 的仓库中的「Actions」界面就能看到创建的 workflow 是否成功执行:

n

如果提示 workflow 全部完成,转至 TestPyPI 的项目界面就能找到刚发布的 package:

n

根据页面的提示,这时候通过 pip install -i https://test.pypi.org/simple/ leo-gh-action-demo 就能将发布的 package 安装到 Python 坏境中。

更复杂一些

好了,经过以上的步骤,我们就完成将 Python package 发布到 PyPI 的基本目标了。不过在实际中会有更复杂的 workflow,考虑这样的开发场景:

  1. 每个分支的每次 push 都要执行 pdm build 检查项目是否能构建;
  2. 每个打上版本号 tag(如 v1.2.1rc1)的 commit 都是准备发布的版本,需要自动创建 pre-release 并将其发布到 TestPyPI,用于测试各种功能;
  3. 确认无误后手动从 tag 创建正式 release,同时自动发布到 PyPI。

分析以上需求,可以划分为 2 个 workflow 实现:

  1. 测试构建 workflow
    • 由 push 触发 pdb build
      • 如果 push 中有版本 tag,则进一步执行 pdm publish,将构建文件发布到 GitHub release 和 TestPyPI;
      • 若无版本 tag,则结束 workflow。
  2. 正式发布 workflow
    • 由手动 release 触发 pdm build,自动下载 release 中的文件并执行 pdm publish,正式发布到 PyPI。

测试构建 workflow

按上文的步骤在 TestPyPI 中创建新的 Publisher,填入 workflow 的文件名,例如 build_py_dist.yml。接着在 .github/workflows 中创建同名文件,写入 workflow 内容:

name: Build Python distributions

# 由 push 触发
on:
  push

jobs:
  # 分别在不同平台构建 .whl 安装包
  build_whl:
    strategy:
      # 使用 matrix 组合在多种 OS 平台上完成 wheel 的构建
      matrix:
        os: [ubuntu-22.04, windows-2022]
        # 给对应的 OS 添加一个新变量 plat_name
        # Linux plat_name 中的 2_35 来自于 GNU libc 版本
        include:
          - os: ubuntu-22.04
            plat_name: manylinux_2_35_x86_64
          - os: windows-2022
            plat_name: win_amd64

    name: Build wheels on ${{ matrix.os }}
    runs-on: ${{ matrix.os }}

    steps:
      - uses: actions/checkout@v3
      - uses: pdm-project/setup-pdm@v3
        with:
          architecture: x64

      # 查看 GNU libc 版本
      - run: ldd --version
      # 通过 PDM 构建 wheel
      - name: PDM build wheels
        # 指定 `--no-sdist` 参数时只生成二进制 wheel 文件
        run: pdm build --no-sdist --config-setting="--plat-name=${{ matrix.plat_name }}"

      # 将 dist/ 目录中的所有 .whl 文件上传暂存
      - uses: actions/upload-artifact@v4
        with:
          name: pdm-build-wheel-${{ matrix.plat_name }}
          path: dist/*.whl

  # 将源码打包为 tar.gz
  build_sdist:
    name: Build source distribution
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
      - uses: pdm-project/setup-pdm@v3

      # 通过 PDM 打包源码
      - name: PDM build source dist
        # 指定 `--no-wheel` 参数时只生成打包的源码
        run: pdm build --no-wheel

      # 将 dist/ 目录中的打包的源码上传暂存
      - uses: actions/upload-artifact@v4
        with:
          name: pdm-build-sdist
          path: dist/*.tar.gz

  # 使用构建文件完成 pre-release
  pre_release:
    name: Pre-release package distributions to GitHub
    # 只有 push 的 tag 以 "v" 起始时才运行该 job
    if: startsWith(github.event.ref, 'refs/tags/v')
    # 且在 build_whl 和 build_sdist 两个 job 完成的情况下执行
    needs: [build_whl, build_sdist]
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
    - uses: actions/checkout@v3
    # 每个 job 都会使用新的容器,需要将上传暂存的构件下载到 dist/ 目录
    - uses: actions/download-artifact@v4
      with:
        pattern: pdm-build-*
        path: dist
        merge-multiple: true
    # 使用 dist/ 目录中的文件创建一个 pre-release
    - uses: ncipollo/release-action@v1
      with:
        artifacts: "dist/*"
        prerelease: true

  # 将构建文件发布到 TestPyPI
  publish_pkg:
    name: Publish package to TestPyPI
    # 只有 push 的 tag 以 "v" 起始时才运行该 job
    if: startsWith(github.event.ref, 'refs/tags/v')
    needs: [build_whl, build_sdist]
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write

    steps:
    # 使用 PDM 发布已构建的文件
    - uses: actions/checkout@v3
    - uses: pdm-project/setup-pdm@v3
    - uses: actions/download-artifact@v4
      with:
        pattern: pdm-build-*
        path: dist
        merge-multiple: true
    # `pdm publish --no-build` 会自动发布 `dist` 中预先构建好的文件
    - name: Publish package distributions to TestPyPI
      run: pdm publish --no-build --repository testpypi

这个 workflow 需要执行的任务比较多,而且涉及了逻辑判断,看起来就比较复杂。各个步骤的介绍已经放在注释中,我想再简单介绍一下其中用到有意思的功能和要注意的细节。

strategy.matrix 可以对 n 组变量做笛卡尔积,组合变量创建出子任务;使用 include 可以为特定条件下的子任务引入新变量。将二者放在一起使用,就能实现一个简单的字典,通过键值对来指定变量。在上面的 workflow 中使用它们就是为了得到 {"ubuntu-22.04": "manylinux_2_35_x86_64", "windows-2022": "win_amd64"},根据不同的平台为 wheel 文件分配不同的后缀。

前文提过,每个 job 并行执行,所以需要使用 needs 来设定执行先决的 job,就能按需组织成任务序列。此外,每个 job 都会使用新的容器,也就是不同 job 间生成的文件是不互通的,因此我通过 upload-artifactdownload-artifact 两个 action 上传和下载文件,实现不同容器间文件的传输。

测试一下构建的情景,具体的步骤是:

  1. 修改 pyproject.toml 中的 version,例如 0.1.1b1;
  2. 通过 git push 推送 commit 到远端仓库。

n

每次的 push 都会自动检查是否能构成功构建,因为没有 tag 条件并不执行 publish 任务

测试发布具体的步骤是:

  1. 修改 pyproject.toml 中的 version,例如 0.1.1b1;
  2. 通过 git tag v0.1.1b1 为当前分支最新 commit 打上版本 tag;
  3. 通过 git push origin v0.1.1b1 推送 tag 到远端仓库。

n

每次的 push tag 都会在构建完成后自动发布到 pre-release

n

同时也会自动发布到 TestPyPI

 NotePEP 440 制定了 Python package 的版本号规范,在 PyPI 上发布自己的 package 时理应也要按该规范设定版本。

正式发布 workflow

相比之下,正式发布的工作流程就简单了许多,就不作过多介绍了。同样需要在 PyPI 中添加 Publisher,在 .github/workflows 中创建 publish_pypi.yml,写入以下内容:

name: Publish distributions to PyPI

# 由正式 release 触发
on:
  release:
    types: [released]
jobs:
  # 将构建文件发布到 PyPI
  publish_pkg:
    name: Publish package to PyPI
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write

    steps:
    - uses: actions/checkout@v3
    - uses: pdm-project/setup-pdm@v3
    # 从 release 中下载所有文件到 dist/ 目录
    - uses: robinraju/release-downloader@v1.10
      with:
        latest: true
        fileName: "*"
        out-file-path: "dist"
    # 使用 PDM 将下载的文件发布到 PyPI
    - name: Publish package distributions to PyPI
      run: pdm publish --no-build

正式发布的具体步骤就是:

  1. 在 GitHub 仓库的 release 中找到正式版本的 pre-release;
  2. 编辑 pre-release,将其改为 release,提交后就会自动发布到 PyPI。

References