本节目标:亲手把 15.1 的 pyproject.toml 构建成 sdist 与 wheel,装进干净环境跑通命令行入口,并理解发布到 PyPI 的完整流程。
适用版本:Python 3.12+(实测 3.14.6)
15.2 构建 wheel、入口点与发布 PyPI
上一节把配置写对了,但配置只是「图纸」。这一节我们真正开动机器:把项目打成可分发产物、装到干净环境验证、再走一遍发布流程。全程用实测输出说话——构建工具 build 1.6.1 已装好。
15.2.1 sdist 与 wheel 的区别
python -m build 默认同时产出两种格式,它们的分工完全不同:
| 维度 | sdist(.tar.gz) | wheel(.whl) |
|---|---|---|
| 全称 | source distribution | built distribution |
| 内容 | 源码 + 构建脚本 | 已构建好的文件树 |
| 安装时 | 需现场构建 | 直接解压就位 |
| 速度 | 慢,依赖构建工具链 | 快,纯复制 |
| 平台 | 平台无关 | 可能绑定平台与 ABI |
| 用途 | 源码存档、喂给 wheel 构建 | pip 安装的首选 |
pip 优先选 wheel:有匹配当前平台与 Python 版本的 wheel 就直接装,装不了才退回 sdist 现场构建。这也是为什么给纯 Python 库发布 wheel 后,用户 pip install 会快得多。
15.2.2 wheel 文件名逐段解码
wheel 文件名是结构化的,格式为 {分发名}-{版本}(-{构建号})?-{Python标签}-{ABI标签}-{平台标签}.whl。先看我们马上要构建的产物:
greetlib-0.1.0-py3-none-any.whl
│ │ │ │ │
│ │ │ │ └─ 平台标签 any:不挑操作系统与 CPU
│ │ │ └────── ABI 标签 none:不含 C 扩展,无 ABI 要求
│ │ └────────── Python 标签 py3:任何 Python 3 都能装
│ └─────────────── 版本号
└────────────────────── 分发名(连字符规范化)
纯 Python 包永远是 py3-none-any:没有编译代码,所以不绑定具体 CPython 小版本(py3 而非 cp314)、不绑定 ABI(none)、不绑定平台(any)。一个文件全平台通用。
含 C 扩展的包则完全不同,比如 macOS 上的平台轮:
cryptolib-2.1.0-cp314-cp314-macosx_11_0_arm64.whl
│ │ │
│ │ └─ 平台:macOS 11.0 起、arm64 架构
│ └─────── ABI:CPython 3.14 的 C ABI
└───────────── Python:仅限 CPython 3.14
这种包每个 Python 版本 × 每个平台都得单独构建一个。本机实测的当前平台标签是 cp314-cp314-macosx_26_0_arm64(macOS 版本随系统而变),可见平台标签里的系统版本号会跟着构建机走。
15.2.3 真实构建一次
准备一个最小项目,目录结构如下(src 布局,2.3 节推荐过):
greetlib/
├── pyproject.toml
├── README.md
└── src/
└── greetlib/
├── __init__.py
├── core.py
├── cli.py
└── data/
└── wordlist.txt
执行构建:
python -m build
真实输出(节选):
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
- setuptools>=68
* Getting build dependencies for sdist...
...
* Installed build dependency versions:
- setuptools==84.0.0
* Building sdist...
...
Successfully built greetlib-0.1.0.tar.gz and greetlib-0.1.0-py3-none-any.whl
注意两件事。第一,build 默认创建隔离环境,按 [build-system].requires 现装后端(这里装了 setuptools==84.0.0),所以构建机不需要预先装好后端。第二,build 先打 sdist,再从 sdist 里构建 wheel——这保证了「从源码也能构建出同样的 wheel」。
15.2.4 dist/ 里有什么
构建完成后产物落在 dist/:
greetlib-0.1.0-py3-none-any.whl # 2569 字节
greetlib-0.1.0.tar.gz # 1987 字节
wheel 本质是个 zip,可以用标准库 zipfile 查看内部:
python -m zipfile -l dist/greetlib-0.1.0-py3-none-any.whl
greetlib/__init__.py
greetlib/cli.py
greetlib/core.py
greetlib/data/wordlist.txt
greetlib-0.1.0.dist-info/METADATA
greetlib-0.1.0.dist-info/WHEEL
greetlib-0.1.0.dist-info/entry_points.txt
greetlib-0.1.0.dist-info/top_level.txt
greetlib-0.1.0.dist-info/RECORD
*.dist-info/ 是元数据目录:METADATA 是从 [project] 生成的包信息,WHEEL 记录轮子版本与标签,entry_points.txt 存入口点,RECORD 是每个文件的哈希清单(用于卸载与校验)。METADATA 里能看到 [project] 字段被如实转换(节选头部字段):
Metadata-Version: 2.4
Name: greetlib
Version: 0.1.0
License-Expression: MIT
Requires-Python: >=3.12
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Provides-Extra: dev 正是 15.1 的 optional-dependencies 落成的结果。
15.2.5 入口点:从 [project.scripts] 到命令行命令
在 pyproject.toml 里声明:
[project.scripts]
greet = "greetlib.cli:main"
这行的含义是「greetlib.cli 模块里的 main 函数,绑定成叫 greet 的命令」。构建后它写进 entry_points.txt:
[console_scripts]
greet = greetlib.cli:main
把 wheel 装进一个全新的临时 venv(不污染开发环境),再执行命令:
python -m venv /tmp/testenv
/tmp/testenv/bin/python -m pip install dist/greetlib-0.1.0-py3-none-any.whl
/tmp/testenv/bin/greet
Hello, Python!
package data words: ['alpha', 'bravo', 'charlie']
installed version: 0.1.0
安装时 pip 自动生成了 bin/greet 脚本,内容是:
#!/private/tmp/python_book/scratch/ch15/testenv/bin/python
import sys
from greetlib.cli import main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(main())
这正是 [project.scripts] 的等价展开——它替你写了「导入入口函数并调用」的样板。三行输出同时验证了三件事:包能 import、包数据文件随 wheel 一起装了进来、importlib.metadata 能读到自己安装后的版本号。
15.2.6 包数据与 MANIFEST.in
wordlist.txt 不是 .py 文件,默认不会被打进 wheel。我们在 pyproject.toml 里显式声明:
[tool.setuptools.package-data]
greetlib = ["data/*.txt"]
sdist 与 wheel 的清单是两套机制:sdist 由 MANIFEST.in 或后端的默认规则决定「哪些文件进源码包」,wheel 由 package-data 决定「哪些数据文件进安装包」。如果只想要「构建 wheel 时用到的源文件」,现代做法更简单——把 sdist 打成 wheel 的必经流程,build 会自动带上。MANIFEST.in 主要留给需要额外夹带文件(如测试数据、模板)的老式场景:
include LICENSE
include README.md
recursive-include src/greetlib/data *.txt
15.2.7 [project.entry-points]:插件机制
[project.scripts] 是 [project.entry-points."console_scripts"] 的语法糖。真正的通用形式是自定义组:
[project.entry-points."myapp.plugins"]
csv_loader = "myapp_plugins.csv:Loader"
宿主程序用 importlib.metadata.entry_points(group="myapp.plugins") 就能枚举出所有第三方注册的插件,无需硬编码导入路径。这正是 pytest 发现插件、Flask 注册扩展的底层机制——入口点是 Python 生态的「松耦合扩展点」。
15.2.8 twine check 与发布流程
产物上传前要先自检元数据。twine check 会读取 wheel 与 sdist 的元数据,检查长描述能否被 PyPI 正常渲染(比如 README 里的相对图片链接会在这里报警):
twine check dist/*
说明:本机预装环境里没有 twine(
python -m twine报No module named twine),所以此处只讲作用、未实际运行。它不改产物,只做只读校验,是上传前的标准动作。
发布流程分为两站。TestPyPI 是演习场,PyPI 是正式库,两者账号与 token 互相独立:
| TestPyPI | PyPI | |
|---|---|---|
| 地址 | test.pypi.org | pypi.org |
| 用途 | 演练上传流程 | 正式分发 |
| 包会被谁装到 | 几乎没人 | 全世界 |
先传 TestPyPI 演练(只写不跑,避免污染真实环境):
twine upload --repository testpypi dist/*
认证用 API token,写进 ~/.pypirc:
[distutils]
index-servers =
pypi
testpypi
[testpypi]
repository = https://test.pypi.org/legacy/
username = __token__
password = pypi-AgEIcHlwaS5vcmc...(你的 token)
[pypi]
username = __token__
password = pypi-AgEIcHlwaS5vcmc...(你的 token)
username 固定写 __token__,密码填 token 本身。演练通过后,把 --repository testpypi 换成默认即可传正式库。
版本号不可复用是一条硬规则:PyPI 上 greetlib==0.1.0 一旦发布,这个「名字 + 版本」组合就永久占用,即使你 yank(撤回)也不能再用同号重新上传。所以每次上传前必须递增版本号,这也是为什么发布流程要跟 Git 标签、CI 严格绑定。
15.2.9 .gitignore 该忽略什么
构建产物与缓存不该进版本库,.gitignore 至少包含:
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
.venv/
*.egg-info/ 是 setuptools 在源码树里留下的中间元数据,build/ 与 dist/ 是构建临时目录与产物目录。注意 dist/ 被忽略后,CI 里的「构建 → 上传」必须在同一次运行内完成,否则产物不会留存。
小结
- sdist 是源码包、wheel 是预构建包;pip 优先装 wheel,装不上才现场构建。
- wheel 文件名分段:
分发名-版本-Python标签-ABI标签-平台标签;纯 Python 包恒为py3-none-any。 python -m build默认在隔离环境里按[build-system].requires现装后端,先打 sdist 再由 sdist 构建 wheel。[project.scripts]生成控制台命令;更通用的[project.entry-points]是插件发现机制。twine check上传前只读校验元数据;TestPyPI 演练、PyPI 正式发布,认证用__token__+ API token。- 版本号一经发布不可复用,发布前必须递增。
到这里我们已经能把一个包构建、安装、验证并发布了。但「装哪些版本」还没定死——下一节解决依赖锁定与可复现构建,让同一个项目在任何机器上都装出一模一样的环境。
阅读导航:上一节:15.1 pyproject.toml 与依赖管理 · 下一节:15.3 版本约束、锁定与可复现构建 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。