「
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。后端负责把源码目录变成一个符合规范的产物。
| 后端 | 典型配置 | 特点 |
|---|---|---|
| setuptools | setuptools.build_meta | 生态最广,兼容老项目,配置冗长 |
| hatchling | hatchling.build | 现代化,默认 src 布局,扩展插件丰富 |
| poetry-core | poetry.core.masonry.api | 与 Poetry 工具链绑定 |
| flit_core | flit_core.buildapi | 极简,适合纯 Python 单模块包 |
| pdm-backend | pdm.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.gz | mypkg-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 短期凭据替代:
- PyPI 项目 → Publishing → 添加 Pending Publisher
- 填写仓库名、workflow 文件名、可选 environment
- 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 分发渠道选择的判断顺序
- 公开、通用、纯 Python → PyPI(唯一选择)
- 公开、带二进制依赖 → PyPI + conda-forge
- 企业内部 → 私有索引(或 monorepo 内 editable 安装)
- 单机交付、无 Python 环境 → 打包为可执行文件或容器
- 模型/数据集 → 对象存储 + 下载式获取,不进包索引
先确定「用户如何拿到它」,再决定构建产物形态,顺序反了就会出现「为了发 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 未拉取 tag | fetch-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 都是一次零心智负担的交付。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。