最近换用 PDM 作为主要的 Python 环境管理工具,虽然使用细节上还不太熟悉,但终究是搭配着 Anaconda 用起来了。PDM 是一款轻巧的工具,但它却涵盖了 Python 开发中的各种场景,例如自动生成项目的 pyproject.toml
,自动解决 package 的版本依赖问题,就算我还未使用很久,也已经为之着迷了。
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 的方式发布。
拖动页面至最底部,在「Add a new pending publisher」中选择「GitHub」,按要求填入 Publisher 的信息:
必填项包括以下 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:
添加 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 是否成功执行:
如果提示 workflow 全部完成,转至 TestPyPI 的项目界面就能找到刚发布的 package:
根据页面的提示,这时候通过 pip install -i https://test.pypi.org/simple/ leo-gh-action-demo
就能将发布的 package 安装到 Python 坏境中。
更复杂一些
好了,经过以上的步骤,我们就完成将 Python package 发布到 PyPI 的基本目标了。不过在实际中会有更复杂的 workflow,考虑这样的开发场景:
- 每个分支的每次 push 都要执行
pdm build
检查项目是否能构建; - 每个打上版本号 tag(如 v1.2.1rc1)的 commit 都是准备发布的版本,需要自动创建 pre-release 并将其发布到 TestPyPI,用于测试各种功能;
- 确认无误后手动从 tag 创建正式 release,同时自动发布到 PyPI。
分析以上需求,可以划分为 2 个 workflow 实现:
- 测试构建 workflow
- 由 push 触发
pdb build
,- 如果 push 中有版本 tag,则进一步执行
pdm publish
,将构建文件发布到 GitHub release 和 TestPyPI; - 若无版本 tag,则结束 workflow。
- 如果 push 中有版本 tag,则进一步执行
- 由 push 触发
- 正式发布 workflow
- 由手动 release 触发
pdm build
,自动下载 release 中的文件并执行pdm publish
,正式发布到 PyPI。
- 由手动 release 触发
测试构建 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-artifact
和 download-artifact
两个 action 上传和下载文件,实现不同容器间文件的传输。
测试一下构建的情景,具体的步骤是:
- 修改
pyproject.toml
中的version
,例如0.1.1b1
; - 通过
git push
推送 commit 到远端仓库。
每次的 push 都会自动检查是否能构成功构建,因为没有 tag 条件并不执行 publish 任务
测试发布具体的步骤是:
- 修改
pyproject.toml
中的version
,例如0.1.1b1
; - 通过
git tag v0.1.1b1
为当前分支最新 commit 打上版本 tag; - 通过
git push origin v0.1.1b1
推送 tag 到远端仓库。
每次的 push tag 都会在构建完成后自动发布到 pre-release
同时也会自动发布到 TestPyPI
Note PEP 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
正式发布的具体步骤就是:
- 在 GitHub 仓库的 release 中找到正式版本的 pre-release;
- 编辑 pre-release,将其改为 release,提交后就会自动发布到 PyPI。