Python 打包发布:构建、签名与 PyPI 分发

Python 打包发布完整实践:构建后端选型、sdist 与 wheel 差异、动态版本、Sigstore 签名与 SLSA 溯源、Trusted Publishing OIDC 免密上传、私有索引与发布流水线,附 pyproject.toml 与 CI 模板。

「pip install 一行就装上了」的背后,是构建后端、产物格式、元数据、签名与分发渠道的一整条流水线。本文把 Python 包从源码目录推到用户机器的全过程拆开讲清楚,重点放在容易被忽略的产物形态与供应链完整性上。

很多团队写完了库,发布时只记得 python -m build && twine upload dist/*,却说不清 dist/ 里为什么有两个文件、为什么 Linux 上要装 15 分钟而 macOS 上秒装、为什么 PyPI 上突然出现一个自己没发过的版本。这些问题都指向同一个根因:对打包产物格式与分发信任链缺乏理解。

1. 构建后端与项目布局

1.1 构建后端(Build Backend)是什么

PEP 517 定义了一个标准接口:前端工具(pip、build、uv)不再自己执行 setup.py,而是调用后端暴露的钩子 build_wheel、build_sdist、prepare_metadata_for_build_wheel。后端负责把源码目录变成一个符合规范的产物。

后端典型配置特点
setuptoolssetuptools.build_meta生态最广,兼容老项目,配置冗长
hatchlinghatchling.build现代化,默认 src 布局,扩展插件丰富
poetry-corepoetry.core.masonry.api与 Poetry 工具链绑定
flit_coreflit_core.buildapi极简,适合纯 Python 单模块包
pdm-backendpdm.backend支持 PEP 621,配置集中
[build-system]
requires = ["hatchling>=1.25"]
build-backend = "hatchling.build"

[project]
name = "mypkg"
version = "0.3.0"
requires-python = ">=3.10"
dependencies = ["httpx>=0.27", "pydantic>=2.7"]

requires 里写的是构建时依赖,不是运行时依赖。它们会被前端下载到一个隔离的构建环境里执行,所以不要在这里塞 numpy 之类运行时才需要的东西,否则每次构建都要重新拉一遍大包。

1.2 源码布局:src 还是平铺

# src 布局(推荐)
mypkg/
├── pyproject.toml
├── README.md
├── src/
│   └── mypkg/
│       ├── __init__.py
│       └── core.py
└── tests/
    └── test_core.py

# 平铺布局(老项目常见)
mypkg/
├── mypkg/
│   └── __init__.py
└── tests/

src 布局的核心好处是强制隔离:测试只能导入已安装的包,而不能因为当前目录恰好有同名文件夹就意外导入源码。这能提前暴露「忘记把子包写进打包清单」的问题——平铺布局下这类错误往往要到用户安装后才炸。若你的库要与 Python 现代工具链 中的 uv、ruff 配合,src 布局是默认约定。

1.3 依赖声明与可选依赖

运行时依赖写在 dependencies,可选的按功能分组:

[project]
dependencies = ["httpx>=0.27,<1.0"]

[project.optional-dependencies]
cli = ["typer>=0.12"]
pandas = ["pandas>=2.0"]
dev = ["pytest>=8", "mypy>=1.10", "ruff>=0.5"]
pip install "mypkg[cli]"       # 装主包 + CLI 依赖
pip install "mypkg[pandas,cli]"  # 多组组合
uv sync --extra dev            # uv 的等价写法

三条经验:依赖区间要留余量(写 httpx>=0.27 而不是 httpx==0.27.0,否则用户一旦与其它包冲突就无解);上界只在已知破坏性变更时加(如 pydantic>=2,<3);可选依赖不要出现在默认安装路径上,否则「轻量库」的名声会在一夜之间消失。发布到 PyPI 前建议跑一次 pip install --dry-run mypkg 观察解析出的依赖树,确认没有意外引入重量级包。

1.4 打包清单与 MANIFEST

构建后端默认只收录包内 .py 文件,非代码资源(模板、数据文件、类型存根)必须显式声明:

[tool.hatch.build.targets.wheel]
packages = ["src/mypkg"]
include = [
  "src/mypkg/**/*.py",
  "src/mypkg/py.typed",
  "src/mypkg/templates/*.html",
]

验证方式不是看源码目录,而是解压产物:

python -m build --wheel
unzip -l dist/mypkg-0.3.0-py3-none-any.whl | head -30

漏文件是最常见的发布事故,且只在用户运行时才暴露。养成「构建后 unzip -l 扫一眼」的习惯,成本几秒钟。

2. 产物格式:sdist 与 wheel

2.1 两种产物的本质区别

维度sdist(.tar.gz)wheel(.whl)
内容源码 + 构建脚本已构建好的文件树
安装方式用户机器上现构建直接解压到 site-packages
是否需要编译器需要(除非纯 Python)不需要
可复现性依赖用户环境强,字节级确定
命名mypkg-0.3.0.tar.gzmypkg-0.3.0-py3-none-any.whl

pip install mypkg 时,pip 会优先找匹配当前平台的 wheel;找不到才回退到 sdist,此时会在本地执行构建。这就是为什么某些包在 Linux CI 上装得特别慢——它们没有发布对应平台 wheel,只能现场编译。

2.2 wheel 文件名解码

wheel 文件名是分段的,每段都有语义:

mypkg-0.3.0-py3-none-any.whl
│     │     │   │    └── 平台标签:any 表示与平台无关
│     │     │   └─────── ABI 标签:none 表示不依赖特定 ABI
│     │     └─────────── Python 标签:py3 表示 Python 3 通用
│     └───────────────── 版本
└─────────────────────── 规范化后的包名

带 C 扩展的包会变成 mypkg-0.3.0-cp312-cp312-manylinux_2_17_x86_64.whl,即绑定 CPython 3.12 ABI 与 manylinux 2017 基线。若你的扩展只用了稳定 ABI(Stable ABI),可以构建 cp38-abi3 的 wheel,一个文件覆盖 3.8 以上所有版本——这正是 Python C 扩展与 FFI 中 Py_LIMITED_API 的直接收益。

2.3 构建命令

# 安装构建前端
uv tool install build

# 构建 sdist + wheel(默认输出到 dist/)
python -m build

# 只构建 wheel
python -m build --wheel

# 用 uv 构建(更快,自动管理构建环境)
uv build

# 检查产物元数据是否合法
python -m twine check dist/*

构建时务必从干净目录出发:dist/ 里残留的旧产物会被一并上传,导致 PyPI 上出现版本错乱。CI 中应 rm -rf dist/ build/ 后再构建。

2.4 用 cibuildwheel 构建多平台 wheel

带 C 扩展的包不可能靠一台机器产出所有平台的 wheel。cibuildwheel 在 CI 中为每个目标平台启动对应容器,批量构建并测试:

# .github/workflows/wheels.yml
jobs:
  build_wheels:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-14, windows-latest]
    steps:
      - uses: actions/checkout@v4
      - uses: pypa/cibuildwheel@v2.20
        env:
          CIBW_BUILD: "cp310-* cp311-* cp312-*"
          CIBW_SKIP: "*-musllinux_i686"
          CIBW_ARCHS_MACOS: "x86_64 arm64"
          CIBW_TEST_COMMAND: "python -c 'import mypkg; print(mypkg.__version__)'"

关键参数:

变量作用
CIBW_BUILD只构建指定 Python 版本/平台组合
CIBW_SKIP排除不需要的组合(如 32 位)
CIBW_ARCHS_MACOS同时出 x86_64 与 arm64(或 universal2)
CIBW_MANYLINUX_*_IMAGE指定 manylinux 基线镜像
CIBW_TEST_COMMAND构建后立刻在目标环境跑冒烟测试

若扩展只用稳定 ABI,可在 pyproject.toml 里让后端构建 abi3 wheel,把 cp310 cp311 cp312 三份产物合并为一份 cp38-abi3,CI 时间与存储都能砍掉三分之二。代价是只能用受限 API,无法直接访问 PyObject 内部结构。

2.5 构建产物大小优化

# 查看 wheel 里最占空间的条目
unzip -l dist/*.whl | sort -k1 -n -r | head -20

常见瘦身手段:剥离调试符号(strip --strip-unneeded)、关闭 LTO 之外的冗余优化、剔除测试数据与 .pyi 之外的大文件、把可选数据改为下载式。注意不要为了瘦身删掉 py.typed 或类型存根,那会破坏下游体验。

3. 元数据与版本管理

3.1 必填与关键字段

[project]
name = "mypkg"                      # PyPI 上唯一
version = "0.3.0"
description = "One-line summary"    # 会显示在 PyPI 列表
readme = "README.md"                # 长描述,PyPI 详情页正文
license = "MIT"                     # PEP 639 起支持 SPDX 表达式
requires-python = ">=3.10"
authors = [{ name = "Leeting Yan", email = "dev@example.com" }]
classifiers = [
  "Development Status :: 4 - Beta",
  "Programming Language :: Python :: 3.12",
  "Typing :: Typed",
]

[project.urls]
Homepage = "https://github.com/you/mypkg"
Changelog = "https://github.com/you/mypkg/blob/main/CHANGELOG.md"

classifiers 里的 Typing :: Typed 会告诉类型检查器与 IDE:这个包自带 py.typed,类型标注可信。忘记在包内放空的 py.typed 文件,用户侧就会收到「类型信息缺失」提示——这是最常见的发布疏漏之一。

3.2 动态版本:从 Git 标签推导

手写版本号必然忘记更新。让版本来自 Git tag:

[project]
name = "mypkg"
dynamic = ["version"]

[tool.hatch.version]
source = "vcs"

[tool.hatch.build.hooks.vcs]
version-file = "src/mypkg/_version.py"
# 打标签即发布版本
git tag -a v0.3.0 -m "release 0.3.0"
python -m build   # 产物自动命名为 mypkg-0.3.0-*

用 setuptools_scm 时同理,只是配置节换成 [tool.setuptools_scm]。要点是 CI 里必须 fetch-depth: 0 拉全量历史与 tag,否则版本推导会退化成 0.1.dev1+gf3a9c1 这种本地版本号,而本地版本号(local version)无法上传到 PyPI。

3.3 语义化版本与预发布

版本写法PyPI 行为适用场景
1.2.3正式版稳定发布
1.3.0a1 / 1.3.0b2 / 1.3.0rc1预发布,pip install 默认跳过灰度验证
1.3.0.dev1开发版,默认跳过每日构建
1.2.3.post1后置版本,排序高于 1.2.3补发元数据
1.2.3+local本地版本,禁止上传 PyPI内部构建

预发布版要用 pip install --pre mypkg 才会被选中。若团队内部需要联调,建议发 rc 到 TestPyPI 而非正式索引。

3.4 入口点与命令行脚本

[project.scripts] 声明控制台脚本,安装后会在 bin/(Windows 是 Scripts\)生成可执行入口:

[project.scripts]
mypkg = "mypkg.cli:main"

[project.gui-scripts]
mypkg-gui = "mypkg.gui:main"

[project.entry-points."mypkg.plugins"]
json = "mypkg.plugins.json:JsonPlugin"
# src/mypkg/cli.py
def main() -> int:
    ...
    return 0

三类入口点的用途不同:scripts 是普通命令行程序,gui-scripts 在 Windows 上不会弹黑框,entry-points 组是插件发现的注册表——宿主程序用 importlib.metadata.entry_points(group="mypkg.plugins") 遍历,从而实现「第三方包不修改主程序即可扩展」的机制。注意入口点函数应当自己捕获异常并返回整数退出码,把 traceback 留给日志而不是直接抛给用户。

3.5 元数据校验

# 检查元数据完整性与 README 渲染
python -m twine check dist/*

# 直接读产物元数据(不安装)
python -c "
from importlib.metadata import metadata
import zipfile
# 或解压后读 mypkg-0.3.0.dist-info/METADATA
"

twine check 会拦截 README 中不合法的相对链接、缺失的长描述等内容问题。把它放进 CI 的 pre-publish 步骤,可以在真正上传前拦住绝大多数低级错误。

4. 签名与供应链完整性

4.1 为什么需要签名

PyPI 上的包可能被投毒(typosquatting)、账号被盗后发布恶意版本、或 CDN 缓存被篡改。签名让消费者能验证「这个产物确实来自声明的发布者且未被改动」。

4.2 Sigstore 与 PyPI 内置签名

自 2024 年起,PyPI 对所有上传的产物自动生成 Sigstore 签名,公开记录在透明日志(Transparency Log)中,用户可用 pypi-attestations 验证:

uv tool install pypi-attestations

# 校验已下载 wheel 的来源与完整性
pypi-attestations verify pypi \
    --repository https://pypi.org/simple/mypkg/ \
    dist/mypkg-0.3.0-py3-none-any.whl

Sigstore 使用短期证书 + OIDC 身份,无需长期私钥。发布者只要通过 GitHub Actions 的 OIDC 身份上传,签名就自动绑定到「该仓库 + 该 workflow」,比传统 GPG 私钥安全得多。

4.3 Trusted Publishing:免 API Token 上传

传统做法是把 PyPI API token 存进 GitHub Secrets,一旦泄露即等于交出发包权。Trusted Publishing 用 OIDC 短期凭据替代:

  1. PyPI 项目 → Publishing → 添加 Pending Publisher
  2. 填写仓库名、workflow 文件名、可选 environment
  3. CI 中声明 id-token: write 权限
name: Publish
on:
  push:
    tags: ["v*"]

jobs:
  publish:
    runs-on: ubuntu-latest
    environment: release
    permissions:
      id-token: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: astral-sh/setup-uv@v5
      - run: uv build
      - uses: pypa/gh-action-pypi-publish@release/v1

注意 fetch-depth: 0(动态版本需要)与 environment: release(配合 PyPI 侧的环境约束可进一步收紧授权范围)。这套流水线形态与 GitHub Actions 与 Node.js CI 中构建发布链路的思路一致:构建与发布分离、凭据最小化、产物不可变。

4.4 可复现构建

# 验证两次构建产物是否字节一致
python -m build --wheel
sha256sum dist/*.whl
rm -rf dist && python -m build --wheel
sha256sum dist/*.whl   # 哈希应一致

要达成可复现,需固定 SOURCE_DATE_EPOCH、避免把构建时间写进产物、锁定构建依赖版本(requires = ["hatchling==1.25.0"])。多数纯 Python 包在固定后端版本后即可复现;带编译扩展的包还需固定编译器与系统库。

4.5 PEP 740:产物溯源证明

PEP 740 在 Sigstore 之上定义了「索引证明(Index Attestation)」:PyPI 为每个上传的产物生成一份证明,声明它是在哪个仓库、哪个 workflow、哪个 commit 下构建的。消费者可据此回答一个此前无法回答的问题——「这个 wheel 到底是不是从我信任的那份源码构建出来的」。

# 校验并打印溯源信息
pypi-attestations verify pypi \
    --repository https://pypi.org/simple/mypkg/ \
    dist/mypkg-0.3.0-py3-none-any.whl

# 输出中可看到:
#   Predicate: https://docs.pypi.org/attestations/publish/v1
#   Repository: https://github.com/you/mypkg
#   Workflow: .github/workflows/publish.yml
#   Commit: <sha>

这让「供应链安全」从模糊的口号变成可机器校验的断言:企业可以在 CI 中加一道门禁,拒绝安装未附带有效证明或证明指向非白名单仓库的包。

4.6 内部包的信任模型

开源包的信任锚点是公开透明日志;内部包则需要自己搭一套等价机制:

  • 构建在受控 CI 中完成,禁止开发者本机上传
  • 私有索引开启「仅接受签名产物」策略
  • 保留构建日志与产物哈希的审计记录
  • 定期扫描依赖树中的 CVE(pip-audit、uv pip audit)
uv tool install pip-audit
pip-audit --requirement requirements.txt --strict

依赖审计应当作为发布流水线的必过关卡,而非事后补救。把 pip-audit 挂在 pre-publish 上,一旦命中高危 CVE 就直接让流水线失败。

5. 分发渠道

5.1 索引选型

渠道适用说明
PyPI开源公开包全球 CDN,无鉴权
TestPyPI发布演练数据会定期清理,勿依赖
私有索引(devpi/Artifactory)企业内部包可做代理缓存上游
Git 直接安装临时/内部pip install git+https://...
本地 wheel离线交付pip install ./mypkg-0.3.0-py3-none-any.whl

5.2 私有索引与依赖混淆防护

# pip 配置多索引并设置优先级
pip config set global.index-url https://pypi.org/simple
pip config set global.extra-index-url https://pypi.internal.example.com/simple

# uv 用显式索引绑定,避免依赖混淆攻击
uv add --index https://pypi.internal.example.com/simple mypkg

依赖混淆(dependency confusion)攻击的原理是:攻击者在公开 PyPI 上注册与你内部包同名的包,且版本号更高,pip 的多索引机制会优先选中高版本,于是内部构建被注入恶意代码。防护手段是按包名绑定索引而非全局 extra-index-url,或干脆给内部包加公司前缀。

5.3 何时不该发到 PyPI

  • 含内部密钥、内网地址、专有算法的包
  • 体积巨大且与平台强绑定的模型/数据包(应放对象存储)
  • 只服务于单一仓库的私有工具(应做成 monorepo 内的可编辑安装)

这类包更适合走内部索引或容器镜像分发,与 Python 部署与分发 中讨论的镜像与可执行文件路径互补。

5.4 conda-forge:科学计算场景的分发

数据科学栈常需要非 Python 依赖(BLAS、CUDA、GDAL),此时 conda 生态比 wheel 更合适:

# recipe/meta.yaml(conda-forge 配方,模板变量用 jinja 占位)
package:
  name: mypkg
  version: "0.3.0"

source:
  url: https://pypi.org/packages/source/m/mypkg/mypkg-0.3.0.tar.gz
  sha256: <sha256-of-sdist>

build:
  script: python -m pip install . -vv
  noarch: python

requirements:
  host: [python >=3.10, pip, hatchling]
  run: [python >=3.10, numpy >=1.26]

about:
  home: https://github.com/you/mypkg
  license: MIT

维护一份 conda-forge 配方意味着你要对两个生态的兼容性负责。建议只在确实依赖二进制系统库时才双发;纯 Python 包继续走 PyPI,避免无谓的维护成本。

5.5 分发渠道选择的判断顺序

  1. 公开、通用、纯 Python → PyPI(唯一选择)
  2. 公开、带二进制依赖 → PyPI + conda-forge
  3. 企业内部 → 私有索引(或 monorepo 内 editable 安装)
  4. 单机交付、无 Python 环境 → 打包为可执行文件或容器
  5. 模型/数据集 → 对象存储 + 下载式获取,不进包索引

先确定「用户如何拿到它」,再决定构建产物形态,顺序反了就会出现「为了发 PyPI 而硬塞几十 MB 数据」这类别扭设计。

6. 发布流水线与治理

6.1 版本撤销与 yank

# 上传后发现有严重问题:不要删除,而是 yank
# PyPI 网页 → Manage → Releases → Yank

删除版本会破坏已锁定该版本的依赖(pip 解析失败、锁文件校验失败)。yank 只影响新解析,已安装的照常工作,是更温和的手段。yank 后应尽快发布修复版本并在 CHANGELOG 说明。

6.2 发布检查清单

  • dist/ 已清空后重新构建
  • python -m twine check dist/* 通过
  • 版本号与 Git tag 一致
  • README 在 PyPI 详情页渲染正常(相对图片链接要改成绝对)
  • 包内含 py.typed(若标注类型)
  • requires-python 与 classifiers 一致
  • 在干净虚拟环境里 pip install dist/*.whl 并跑冒烟测试
  • 先在 TestPyPI 演练一遍
  • 用 Trusted Publishing 而非长期 token

6.3 与库设计的关系

打包是「对外契约」的载体:包名、公开 API 面、版本策略三者一旦发布就很难收回。发布前请回顾一遍向后兼容的判断标准——一次不兼容的 minor 升级,代价远高于多花半天设计接口。若包结构本身需要调整(如拆分模块、改名子包),应作为 major 版本发布并保留一个周期的弃用垫片。

7. 常见错误排查

7.1 症状与根因对照表

症状根因修复
用户装完 import mypkg 报 ModuleNotFoundError子包未被收录检查 packages/include,unzip -l 验证
PyPI 上传报 File already exists版本号未更新递增版本或 yank 后重发
上传报 InvalidDistribution元数据字段不合法twine check dist/* 定位
版本变成 0.1.dev1+g...CI 未拉取 tagfetch-depth: 0
Linux 上安装耗时数分钟无平台 wheel,现场编译上 cibuildwheel
pip install 选到同名内部包依赖混淆按包绑定索引,弃用 extra-index-url
类型提示失效缺 py.typed包内放空 py.typed 并声明 Typing :: Typed
README 图片在 PyPI 不显示用了相对路径改为绝对 URL

7.2 调试手段

# 看 pip 究竟解析到哪个包、哪个索引
pip install -v mypkg 2>&1 | grep -i "found link\|downloading"

# 看 wheel 的元数据与依赖声明
unzip -p dist/*.whl '*/METADATA' | head -40

# 在纯净环境验证安装(不污染当前环境)
uv venv /tmp/verify && /tmp/verify/bin/pip install dist/*.whl

# 检查包内是否含 py.typed
unzip -l dist/*.whl | grep py.typed

pip install -v 是排查「装错了包」「从哪个索引拉的」的第一手段;纯净环境安装验证则是发布前的最后一道保险。两者加起来不到一分钟,能拦住绝大多数需要撤回版本的事故。

7.3 发布节奏建议

阶段频率做法
开发版每次合并 main发 dev 版本到内部索引
预发布每个里程碑rc 发 TestPyPI 供灰度
正式版按需tag 触发 Trusted Publishing
补丁版出现缺陷仅修 bug,不动 API

保持「正式版只在 tag 时发布」的纪律,能让 PyPI 上的版本历史与 Git 历史严格对应,也便于用户按 tag 追溯变更。

小结

Python 打包的关键认知有三点:产物格式决定安装体验(wheel 优先、abi3 省事)、信任链决定安全边界(Sigstore + Trusted Publishing 已是默认最佳实践)、元数据决定长期可维护性(动态版本、classifiers、py.typed 一次配好终身受益)。把这三件事在第一次发布时就做对,后续每次 git tag 都是一次零心智负担的交付。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. Python 桌面 GUI 应用开发
  2. Python 正则与文本处理进阶
  3. Python GraphQL API:Strawberry 与 Schema 设计