本节目标:把 wheel 与 sdist 拆到文件级,讲清
.dist-info、RECORD、.data/、entry_points的内部约定,并实测 PEP 517 后端钩子与平台标签的匹配规则。
适用版本:Python 3.12+(实测 3.14.6)
11.1 打包与分发机制
站内专题 Python 打包发布:构建、签名与 PyPI 分发
讲的是「怎么发」:后端选型、Sigstore 签名、Trusted Publishing、发布流水线。本节换一个镜头,只回答一个问题——一个 wheel 解压后到底长什么样,每个文件是谁写的,安装器凭什么信任它。下面所有结论都来自本机真打出来的产物(uv 0.12.23)。
11.1.1 先真打一个包
先用一个最小项目练手。src 布局、一个 console script、一个可选依赖、一个 Requires-Python:
# pyproject.toml
[build-system]
requires = ["uv_build>=0.12.23,<0.13.0"]
build-backend = "uv_build"
[project]
name = "greeter"
version = "1.2.0"
description = "A tiny demo package for wheel internals"
requires-python = ">=3.12"
license = "MIT"
dependencies = ["idna>=3"]
[project.optional-dependencies]
cli = ["rich>=13"]
[project.scripts]
greeter = "greeter.__main__:main"
$ uv build --offline proj
Building source distribution...
Building wheel from source distribution...
Successfully built proj/dist/greeter-1.2.0.tar.gz
Successfully built proj/dist/greeter-1.2.0-py3-none-any.whl
一次构建得到两个产物:sdist(.tar.gz,源码包)和 wheel(.whl,二进制分发包)。注意 wheel 是从 sdist 再构建出来的,这也是 PEP 517 规定的路径——sdist 必须能重建出等价 wheel。
11.1.2 wheel 的内部结构:两个顶层目录
解压 wheel 看文件清单:
$ unzip -l greeter-1.2.0-py3-none-any.whl
Length Date Time Name
0 ... greeter/
81 ... greeter/__init__.py
136 ... greeter/__main__.py
0 ... greeter-1.2.0.dist-info/
81 ... greeter-1.2.0.dist-info/WHEEL
51 ... greeter-1.2.0.dist-info/entry_points.txt
505 ... greeter-1.2.0.dist-info/METADATA
449 ... greeter-1.2.0.dist-info/RECORD
wheel 是一个 zip,顶层只有两类目录:被安装的包本身(greeter/),以及一个 <name>-<version>.dist-info/ 元数据目录。dist-info 里四个文件各有分工:
# greeter-1.2.0.dist-info/WHEEL
Wheel-Version: 1.0
Generator: uv 0.12.23
Root-Is-Purelib: true
Tag: py3-none-any
# greeter-1.2.0.dist-info/METADATA
Metadata-Version: 2.4
Name: greeter
Version: 1.2.0
Summary: A tiny demo package for wheel internals
Author-email: Leeting Yan <dev@example.com>
License-Expression: MIT
Requires-Dist: idna>=3
Requires-Dist: rich>=13 ; extra == 'cli'
Requires-Python: >=3.12
Provides-Extra: cli
Description-Content-Type: text/markdown
# greeter-1.2.0.dist-info/RECORD
greeter/__init__.py,sha256=dHW27KIxNL0CKy1Jbyvwk73FrP6RacnPQ2-yGWO-5M0,81
greeter/__main__.py,sha256=-XflsEd0sLnQgGA3Rr-IAjXOqzjlVzvGURixA4hDsfE,136
greeter-1.2.0.dist-info/WHEEL,sha256=cmC5s21ojypbVslldL7IJq3hjZH-tINy4rziKePFsG0,81
greeter-1.2.0.dist-info/entry_points.txt,sha256=l8sOdm8aUSt1QxZbnyHe6JohO13n6p4_WPV8v_u8W00,51
greeter-1.2.0.dist-info/METADATA,sha256=jcs6Kdbzc9borh3fKIkIMQiTZ2GHxud-zzBxJ8Lmm2A,505
greeter-1.2.0.dist-info/RECORD,,
RECORD 每行是 路径,sha256=<urlsafe-base64>,<字节数>,用来做安装后的完整性校验。最后一行是它自己,且哈希与大小留空——因为记录自己的哈希是自指悖论,PEP 376 明确豁免这一行。校验时忽略 RECORD 自身即可。
sdist 的结构完全不同,它是「源码快照」而不是「待安装的文件树」:
$ tar tzf greeter-1.2.0.tar.gz
greeter-1.2.0/PKG-INFO # 等价于 wheel 里的 METADATA
greeter-1.2.0/pyproject.toml
greeter-1.2.0/pyproject.toml.orig # 原始 pyproject,未经后端规范化
greeter-1.2.0/README.md
greeter-1.2.0/src/greeter/__init__.py
greeter-1.2.0/src/greeter/__main__.py
关键区别:sdist 里是源文件 + PKG-INFO,没有 RECORD(还没安装,无从校验),也没有 WHEEL(不是 wheel)。它必须能被一个构建后端重新跑一遍,产出 wheel。
11.1.3 .data/ 目录:wheel 里最容易被忽略的一层
绝大多数包只有「包目录 + dist-info」两类顶层目录。但只要一个包要装脚本、头文件或共享数据,就会多出第三类目录:<name>-<version>.data/。它下面按目标位置分五个子目录:
| 子目录 | 安装到 | 典型用途 |
|---|---|---|
purelib/ | site-packages | 纯 Python 模块 |
platlib/ | site-packages | 平台相关模块(与 purelib 通常同址) |
scripts/ | 虚拟环境 bin/(Windows Scripts/) | 可执行脚本 |
headers/ | include/site/pythonX.Y/<name>/ | C 头文件 |
data/ | 前缀目录 sys.prefix | 共享数据文件 |
本机没有任何已安装包用到 .data/,所以我手工构造一个 wheel 来验证映射关系:
# 构造一个含 .data/ 的 wheel 并安装
datapkg-0.1.0.data/scripts/hello-datapkg # 可执行脚本
datapkg-0.1.0.data/data/share/datapkg/greeting.txt
datapkg-0.1.0.data/headers/datapkg.h
$ uv pip install --no-deps --offline datapkg-0.1.0-py3-none-any.whl
Installed 1 package in 47ms
$ ls -l tvenv/bin/hello-datapkg
-rwxr-xr-x@ 1 ... 40 ... tvenv/bin/hello-datapkg # .data/scripts -> bin/,且自动加可执行位
$ find tvenv -path '*share/datapkg*'
tvenv/share/datapkg/greeting.txt # .data/data -> <prefix>/share/...
$ find tvenv -name datapkg.h
tvenv/include/site/python3.14/datapkg/datapkg.h # .data/headers -> include/site/pythonX.Y/<name>/
安装器会把 .data/ 下的文件搬到目标位置,并在 RECORD 里记下搬过去的最终路径——所以 .data/ 在安装后的 site-packages 里是看不到的。
11.1.4 从 entry_points 到 console script
[project.scripts] 不写进任何 .py,而是落成一个文本文件 entry_points.txt:
# greeter-1.2.0.dist-info/entry_points.txt
[console_scripts]
greeter = greeter.__main__:main
安装时,安装器读这个文件,在 bin/ 里生成一个启动脚本。本机装完后的真实脚本如下:
#!/private/.../tvenv/bin/python
# -*- coding: utf-8 -*-
import sys
from greeter.__main__ import main
if __name__ == "__main__":
if sys.argv[0].endswith("-script.pyw"):
sys.argv[0] = sys.argv[0][:-11]
elif sys.argv[0].endswith(".exe"):
sys.argv[0] = sys.argv[0][:-4]
sys.exit(main())
所以 greeter 命令的本质是:用当前解释器导入 greeter.__main__ 的 main 函数并调用它。这也解释了为什么 console script 必须在目标环境里安装(它绑定的是那个环境的解释器路径),而不能像纯 .py 一样拷来拷去。
安装器还会往 dist-info 里补写几个文件,这是「构建产物」与「已安装状态」的差异:
$ cat tvenv/.../greeter-1.2.0.dist-info/RECORD # 安装后
../../../bin/greeter,sha256=...,337 # 生成的脚本(相对 site-packages)
greeter-1.2.0.dist-info/INSTALLER,sha256=...,2 # 内容就是 "uv"
greeter-1.2.0.dist-info/REQUESTED,sha256=...,0 # 被显式请求(非依赖)
greeter-1.2.0.dist-info/direct_url.json,... # 从哪个 URL 装的
greeter-1.2.0.dist-info/RECORD,, # 自我豁免依旧
INSTALLER 记录「谁装的」(便于 pip uninstall 判断),direct_url.json 记录来源(本地路径 / VCS / 索引),REQUESTED 区分「用户点名装的」与「被依赖拖进来的」——pip list --not-required 就靠它。
11.1.5 PEP 517:构建后端契约与钩子调用顺序
构建器(uv build / python -m build / pip)不直接读 pyproject.toml 组装 wheel,而是调用构建后端实现的一组固定函数(PEP 517 钩子)。为了看清调用顺序,我写了一个最小后端 mybackend.py,每个钩子只打印一行日志:
# miniback/mybackend.py(节选)
def get_requires_for_build_wheel(config_settings=None):
print("[backend] get_requires_for_build_wheel", flush=True)
return []
def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
print("[backend] build_wheel", flush=True)
... # 组装 zip:包文件 + dist-info/{METADATA,WHEEL,RECORD}
return "demo-0.1.0-py3-none-any.whl"
def prepare_metadata_for_build_wheel(metadata_directory, config_settings=None):
print("[backend] prepare_metadata_for_build_wheel", flush=True)
return "demo-0.1.0.dist-info" # 只产出元数据,不构建 wheel
把 [build-system] build-backend = "mybackend" 指向它(配合 backend-path = ["."]),再跑不同工具:
$ uv build --wheel --force-pep517 miniback
[backend] get_requires_for_build_wheel
[backend] build_wheel
Successfully built demo-0.1.0-py3-none-any.whl
$ uv build --force-pep517 miniback # 打 sdist 时换一对钩子
[backend] get_requires_for_build_sdist
[backend] build_sdist
$ python -c "import build; build.ProjectBuilder('miniback').metadata_path('out')"
[backend] prepare_metadata_for_build_wheel
由此可以确定契约的三条事实:
| 钩子 | 何时被调用 | 返回什么 |
|---|---|---|
get_requires_for_build_wheel | 构建 wheel 前 | 额外的构建依赖列表 |
build_wheel | 真正构建时 | wheel 文件名 |
build_sdist | 构建源码包时 | sdist 文件名 |
prepare_metadata_for_build_wheel | 只需要元数据、不想构建时 | dist-info 目录名 |
prepare_metadata_for_build_wheel 是可选优化:解析依赖只需要 METADATA,没必要把整个 wheel 打出来。uv build 走的是 build_wheel 快路径,所以没触发它;而 build 的 metadata_path 会优先调用它。这正是 PEP 517 相比 setup.py install 的核心收益——「构建」与「拿元数据」被拆成两个可独立调用的动作。
11.1.6 文件名与标签:wheel tag 的匹配规则
wheel 文件名不是随便起的,它是 {name}-{version}(-{build})?-{python}-{abi}-{platform}.whl 的结构化编码。用 packaging 解析三个典型文件名:
from packaging.utils import parse_wheel_filename
for fn in ["greeter-1.2.0-py3-none-any.whl",
"numpy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl",
"cryptography-45.0.0-cp39-abi3-manylinux_2_34_x86_64.whl"]:
name, ver, build, tags = parse_wheel_filename(fn)
print(name, ver, sorted(str(t) for t in tags))
greeter 1.2.0 ['py3-none-any']
numpy 2.3.0 ['cp314-cp314-macosx_11_0_arm64']
cryptography 45.0.0 ['cp39-abi3-manylinux_2_34_x86_64']
三个标签段的含义:
- python tag:
py3= 任意 3.x;cp314= CPython 3.14。 - abi tag:
none= 无 ABI 依赖;cp314= CPython 3.14 专属 ABI;abi3= 稳定 ABI(PEP 384),一个 wheel 可跨 3.x 使用。 - platform tag:
any= 纯 Python;manylinux_2_34_x86_64/macosx_11_0_arm64= 具体平台。
本机解释器能接受哪些标签?packaging.tags.sys_tags() 会按优先级列出全部兼容标签:
from packaging.tags import sys_tags
tags = [str(t) for t in sys_tags()]
print(len(tags), tags[:3])
print("py3-none-any 可接受:", "py3-none-any" in tags)
print("abi3 标签样例:", [t for t in tags if "abi3" in t][:2])
1412 ['cp314-cp314-macosx_26_0_arm64', 'cp314-cp314-macosx_26_0_universal2', 'cp314-cp314-macosx_25_0_arm64']
py3-none-any 可接受: True
abi3 标签样例: ['cp314-abi3-macosx_26_0_arm64', 'cp314-abi3-macosx_26_0_universal2']
1412 个标签说明「兼容」是一棵很大的组合树。abi3 的价值在于:用稳定 ABI 编译的 C 扩展只出一个 wheel,就能被 cp39 到 cp314 共用——本机 sysconfig 里的 SOABI 是 cpython-314-darwin、EXT_SUFFIX 是 .cpython-314-darwin.so,普通扩展的 .so 名字里带死了版本号,abi3 则用 .abi3.so 规避了这个绑定。abi3 的代价是不能用全部 C API,第 9.1 ctypes / cffi 与 ABI 稳定性
讲过的 ABI 约束在这里落地。
11.1.7 Requires-Python:写进元数据,由安装器执行
requires-python = ">=3.12" 最终变成 METADATA 里的一行,约束的是目标解释器版本,不是包版本。谁来判断合不合规?安装器。把 Requires-Python 改成 >=3.8,<3.14 后分别用 pip 和 uv 装同一个 wheel:
$ python -m pip install --no-deps --target /tmp/t proj2/.../greeter-1.2.0-py3-none-any.whl
ERROR: Package 'greeter' requires a different Python: 3.14.6 not in '<3.14,>=3.8'
$ uv pip install --no-deps --offline proj2/.../greeter-1.2.0-py3-none-any.whl
Installed 1 package in 98ms # 直接指定 wheel 路径时,uv 不做 requires-python 拦截
这暴露了一个容易被忽略的差异:pip 对本地 wheel 也强制校验 Requires-Python,而 uv 在「用户显式点名一个本地文件」时倾向于尊重用户、不做拦截。所以 requires-python 是一道软门禁——它能不能挡住你,取决于安装器。真正要防「在不兼容解释器上装错包」,还得靠 CI 矩阵(下一节 11.3 会讲)。
小结
- wheel 是一个 zip,顶层只有「包目录 +
<name>-<version>.dist-info/」两类;dist-info里METADATA(依赖与元数据)、WHEEL(格式与标签)、RECORD(哈希清单)、entry_points.txt(入口点)分工明确。 RECORD每行是路径,sha256,字节数,它自己那一行哈希与大小留空(PEP 376 自我豁免)。- 需要装脚本 / 头文件 / 共享数据的包会多出
<name>-<version>.data/,其下scripts/→bin/、headers/→include/site/pythonX.Y/<name>/、data/→<prefix>/;本机手工构造 wheel 实测落点全部吻合。 - console script 是安装时由
entry_points.txt现场生成的启动脚本,导入入口函数再sys.exit(main());安装器还会补写INSTALLER/REQUESTED/direct_url.json并重写RECORD。 - PEP 517 把构建拆成一组钩子:
get_requires_for_build_wheel→build_wheel(打 wheel),get_requires_for_build_sdist→build_sdist(打 sdist),prepare_metadata_for_build_wheel(只取元数据);本机用最小后端实测了调用顺序。 - wheel 文件名是
{name}-{version}-{python}-{abi}-{platform}的结构化编码;本机sys_tags()有 1412 个兼容标签,abi3用稳定 ABI 让一个 C 扩展 wheel 跨 3.x 复用。 Requires-Python写进元数据、由安装器执行,是软门禁:pip 对本地 wheel 也拦截,uv 显式指定路径时不拦截。
理解了 wheel 与 sdist 的物理形态,才能理解为什么「装一个包」会发生这么多事。下一节 11.2 嵌入式与自由线程运行时 换到运行时的另一端:把解释器嵌进 C 程序,以及自由线程构建到底改了什么。
阅读导航:上一节:10.3 自适应特化与 JIT 现状 · 下一节:11.2 嵌入式与自由线程运行时 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。