《Python编程入门》15.2 构建 wheel、入口点与发布 PyPI

构建产物 sdist 与 wheel 到底差在哪?本节逐段解码 wheel 文件名,真实跑一次 python -m build,把 wheel 装进临时 venv 执行 console_scripts 命令,再讲清包数据、入口点插件、twine check、TestPyPI 与 PyPI 的上传流程,以及版本号不可复用这条硬规则。

本节目标:亲手把 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 distributionbuilt 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 互相独立:

TestPyPIPyPI
地址test.pypi.orgpypi.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 版本约束、锁定与可复现构建 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时