《Python高级编程》11.1 打包与分发机制

用 uv build 真打一个包,逐文件拆开 wheel 的 .dist-info、RECORD、METADATA 与 .data/ 目录并实测安装落点,再看 entry_points 如何生成 console script、PEP 517 后端钩子的真实调用顺序,以及 wheel tag、平台标签与 abi3 的匹配规则。

本节目标:把 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 会讲)。

小结

  1. wheel 是一个 zip,顶层只有「包目录 + <name>-<version>.dist-info/」两类;dist-info 里 METADATA(依赖与元数据)、WHEEL(格式与标签)、RECORD(哈希清单)、entry_points.txt(入口点)分工明确。
  2. RECORD 每行是 路径,sha256,字节数,它自己那一行哈希与大小留空(PEP 376 自我豁免)。
  3. 需要装脚本 / 头文件 / 共享数据的包会多出 <name>-<version>.data/,其下 scripts/→bin/、headers/→include/site/pythonX.Y/<name>/、data/→<prefix>/;本机手工构造 wheel 实测落点全部吻合。
  4. console script 是安装时由 entry_points.txt 现场生成的启动脚本,导入入口函数再 sys.exit(main());安装器还会补写 INSTALLER / REQUESTED / direct_url.json 并重写 RECORD。
  5. PEP 517 把构建拆成一组钩子:get_requires_for_build_wheel → build_wheel(打 wheel),get_requires_for_build_sdist → build_sdist(打 sdist),prepare_metadata_for_build_wheel(只取元数据);本机用最小后端实测了调用顺序。
  6. wheel 文件名是 {name}-{version}-{python}-{abi}-{platform} 的结构化编码;本机 sys_tags() 有 1412 个兼容标签,abi3 用稳定 ABI 让一个 C 扩展 wheel 跨 3.x 复用。
  7. Requires-Python 写进元数据、由安装器执行,是软门禁:pip 对本地 wheel 也拦截,uv 显式指定路径时不拦截。

理解了 wheel 与 sdist 的物理形态,才能理解为什么「装一个包」会发生这么多事。下一节 11.2 嵌入式与自由线程运行时 换到运行时的另一端:把解释器嵌进 C 程序,以及自由线程构建到底改了什么。

阅读导航:上一节:10.3 自适应特化与 JIT 现状 · 下一节:11.2 嵌入式与自由线程运行时 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「python」更多文章

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