Python 基础体系 · 第 65/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 包构建与发布:wheel、sdist、索引、签名和版本

Python 项目从源代码变成可安装的软件,至少要经过四个边界:

  1. 项目源树:开发者维护的源码、测试、文档和配置。
  2. 分发文件:可以上传到索引的 .whl.tar.gz 文件。
  3. 包索引:保存分发文件及其元数据,供安装工具查询和下载。
  4. 安装环境:安装工具根据版本、Python 版本、ABI 和平台选择具体文件。

这里的“包”容易产生歧义。pip install example-project 中的名称是 distribution package,即“分发包”;import example_package 使用的是 import package,即“导入包”。两者可以同名,但规范并不要求它们相同。一个分发包也可以提供多个导入包。(packaging.python.org)

本文以 Python 3.14 为目标环境,使用现代的 pyproject.toml、PEP 517 构建接口和 PyPA 定义的分发格式。重点不是某个具体构建后端,而是理解构建文件如何产生、索引如何选择、版本如何比较,以及签名和哈希到底解决什么问题。


一、先建立完整的发布模型

一个典型发布流程可以表示为:

flowchart LR
    A[项目源树] --> B[pyproject.toml]
    B --> C[构建前端]
    C --> D[构建隔离环境]
    D --> E[构建后端]
    E --> F[sdist .tar.gz]
    E --> G[wheel .whl]
    F --> H[校验与测试]
    G --> H
    H --> I[哈希]
    H --> J[签名或 Attestation]
    I --> K[包索引]
    J --> K
    K --> L[安装工具 pip]
    L --> M[候选文件筛选]
    M --> N[安装 wheel]
    M --> O[下载 sdist 后重新构建]

其中:

  • 构建前端负责准备环境、调用构建后端并收集产物。buildpip 都可以充当前端。
  • 构建后端负责把项目源树转换为 sdist 或 wheel,例如 Setuptools、Hatchling、Flit、PDM、Maturin 等。
  • sdist 是源分发格式,通常是 .tar.gz
  • wheel 是构建分发格式,通常是 .whl
  • 索引保存文件、文件名、哈希、Python 版本要求、是否撤回以及可选的元数据和来源证明。

构建后端不是由 python -m build 写死的,而是由 pyproject.toml[build-system] 指定。前端读取该表,创建隔离环境,安装 requires 中的构建依赖,再调用 build-backend 对应的后端对象。(packaging.python.org)


二、pyproject.toml:构建系统与项目元数据的入口

一个最小但现代的项目可以采用如下结构:

demo-package/
├── pyproject.toml
├── README.md
├── LICENSE
├── src/
│   └── demo_package/
│       ├── __init__.py
│       └── cli.py
└── tests/
    └── test_basic.py

pyproject.toml

[build-system]
requires = ["setuptools >= 77.0.3"]
build-backend = "setuptools.build_meta"

[project]
name = "demo-package"
version = "1.2.0"
description = "A small example package"
readme = "README.md"
requires-python = ">=3.14"
license = "MIT"
authors = [
    { name = "Example Author" }
]
dependencies = [
    "httpx >=0.27,<1"
]

[project.scripts]
demo-hello = "demo_package.cli:main"

[tool.setuptools]
package-dir = {"" = "src"}

[tool.setuptools.packages.find]
where = ["src"]

这个文件包含三类信息:

1. [build-system]:如何构建

[build-system]
requires = ["setuptools >= 77.0.3"]
build-backend = "setuptools.build_meta"

requires构建时依赖,不是运行时依赖。它描述的是“为了执行构建后端,构建隔离环境需要先安装什么”。

build-backend 是一个 Python 对象路径。前端会导入它,并调用标准构建接口。构建前端不需要知道 Setuptools 内部如何发现包、如何生成元数据,只需要遵守构建接口。

如果忘记把构建时依赖写进 requires,本机可能因为全局环境恰好安装过相关工具而构建成功,但 CI 或用户从 sdist 构建时会失败:

ModuleNotFoundError: No module named 'setuptools'

因此,pip install 的运行时依赖和 [build-system].requires 的构建时依赖必须分开。前者进入构建出的 METADATA,后者用于创建构建环境,通常不会作为项目运行依赖发布。

2. [project]:生成 Core Metadata

[project] 中的字段会被后端转换为分发文件中的 Core Metadata,例如:

Name: demo-package
Version: 1.2.0
Requires-Python: >=3.14
Requires-Dist: httpx<1,>=0.27

Core Metadata 至少需要 NameVersionMetadata-Version;当前规范定义的 Core Metadata 版本为 2.6。(packaging.python.org)

nameversion 是最重要的两个字段:

  • name 标识哪个项目;
  • version 标识该项目的哪个发布版本。

requires-python 不决定代码是否真的兼容 Python 3.14,它只是向安装工具声明兼容范围。安装工具可以据此在下载前排除不满足条件的文件。

3. [tool]:后端或工具私有配置

[tool.setuptools] 不是通用打包规范中的统一字段,而是 Setuptools 自己定义的配置。其他后端可能使用:

[tool.hatch.build.targets.wheel]
packages = ["src/demo_package"]

或自己的配置表。

因此需要区分:

  • [project]:跨后端共享的项目元数据;
  • [build-system]:选择构建后端并声明构建依赖;
  • [tool.*]:具体后端或其他工具的配置。

三、构建前端、构建后端与隔离环境

执行:

python3.14 -m venv .venv
. .venv/bin/activate

python -m pip install --upgrade build twine
python -m build

默认情况下,python -m build 会构建一个 sdist 和一个 wheel:

dist/
├── demo_package-1.2.0-py3-none-any.whl
└── demo_package-1.2.0.tar.gz

PyPA 推荐使用 build 调用 pyproject.toml 中声明的后端,而不是直接执行:

python setup.py sdist
python setup.py bdist_wheel

后两种命令依赖 Setuptools 的历史接口,不能表达“项目选择了哪个标准构建后端”这一层抽象。(packaging.python.org)

构建过程可以拆成以下状态:

源树
  │
  ├─读取 pyproject.toml
  │
  ├─创建临时隔离环境
  │
  ├─安装 build-system.requires
  │
  ├─调用 build_sdist
  │       └─生成 .tar.gz
  │
  └─调用 build_wheel
          └─生成 .whl

隔离环境解决的是构建依赖污染问题。例如项目需要 setuptools 生成 wheel,但开发环境中安装了多个版本的 Setuptools。如果直接使用全局环境,构建结果可能依赖不可见状态;隔离构建则让 [build-system] 成为构建输入的一部分。

不过,隔离环境不等于可复现构建。若写成:

requires = ["setuptools"]

构建前端可能在不同时间解析到不同版本。要提高可复现性,应明确约束构建依赖版本,并在 CI 中记录构建环境。


四、wheel:面向安装的构建分发格式

4.1 wheel 的本质

wheel 是 ZIP 格式的归档文件,扩展名为 .whl。它包含接近最终安装状态的文件,因此安装时通常只需要解压到目标环境,而不需要重新编译或执行项目的构建步骤。(packaging.python.org)

一个纯 Python wheel:

demo_package-1.2.0-py3-none-any.whl

文件名可以拆成:

{distribution}-{version}-{python tag}-{abi tag}-{platform tag}.whl

对应关系是:

部分 示例 含义
distribution demo_package 分发名称
version 1.2.0 项目版本
python tag py3 Python 实现或版本兼容范围
abi tag none 不依赖特定 ABI
platform tag any 不依赖特定操作系统和架构

wheel 规范定义的文件名形式为:

{distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl

兼容性标签用于让安装工具判断某个 wheel 是否适合当前解释器、ABI 和平台。(packaging.python.org)

4.2 wheel 内部结构

可以查看 wheel:

python -m zipfile -l dist/demo_package-1.2.0-py3-none-any.whl

典型输出类似:

demo_package/__init__.py
demo_package/cli.py
demo_package-1.2.0.dist-info/METADATA
demo_package-1.2.0.dist-info/WHEEL
demo_package-1.2.0.dist-info/entry_points.txt
demo_package-1.2.0.dist-info/RECORD

几个关键文件的职责不同:

  • METADATA:项目名称、版本、依赖、Python 要求等。
  • WHEEL:wheel 格式版本、生成器、纯 Python 与否、兼容性标签。
  • entry_points.txt:旧式文件格式,用于保存入口点信息。
  • RECORD:安装文件清单以及文件哈希;未设置哈希的文件可能没有哈希字段。

wheel 的 RECORD 能帮助卸载工具知道删除哪些文件,但它不是远程发布签名。RECORD 中的哈希用于记录归档内部文件,不能单独证明“这个 wheel 是谁发布的”。

4.3 二进制扩展与多个 wheel

纯 Python 项目通常可以发布:

demo_package-1.2.0-py3-none-any.whl

但含有 C、C++、Rust 等扩展的项目,wheel 往往要按 Python 实现、ABI、操作系统和架构分别构建,例如:

example-1.2.0-cp314-cp314-manylinux_2_28_x86_64.whl
example-1.2.0-cp314-cp314-win_amd64.whl
example-1.2.0-cp314-cp314-macosx_14_0_arm64.whl

安装工具的候选条件可以抽象为:

可选 wheel=版本满足Python 要求满足wheel 标签与当前环境有交集\text{可选 wheel} = \text{版本满足} \land \text{Python 要求满足} \land \text{wheel 标签与当前环境有交集}

令:

  • VcV_c 为当前项目要求的版本约束;
  • vfv_f 为候选文件的项目版本;
  • RfR_f 为文件元数据中的 Requires-Python
  • PP 为当前 Python 环境支持的标签集合;
  • TfT_f 为 wheel 文件声明的标签集合。

则候选条件为:

vfVcPythoncurrentRfTfPv_f \models V_c \land \text{Python}_{current} \models R_f \land T_f \cap P \neq \varnothing

例如,Python 3.14、Linux x86_64 环境不应选择仅支持 CPython 3.13 的 wheel,即使项目版本完全匹配。若没有可用 wheel,安装工具通常会尝试下载 sdist,再在本地构建。(packaging.python.org)


五、sdist:面向构建和下游打包的源分发格式

5.1 sdist 不等于源码压缩包

sdist 通常是:

demo_package-1.2.0.tar.gz

它不是简单的 git archive 或源码目录压缩包,而是一个带有构建元数据的源分发文件。

当前规范要求标准 sdist:

  • 文件名为 {name}-{version}.tar.gz
  • 内部只有一个顶层目录;
  • 顶层目录名是 {name}-{version}
  • 包含 pyproject.toml
  • 包含 PKG-INFO
  • PKG-INFO 至少符合 Core Metadata 2.2;
  • 文件名中的名称和版本必须与内部元数据一致。(packaging.python.org)

例如:

demo_package-1.2.0.tar.gz
└── demo_package-1.2.0/
    ├── pyproject.toml
    ├── PKG-INFO
    ├── README.md
    ├── LICENSE
    ├── src/
    │   └── demo_package/
    └── tests/

5.2 sdist 的安装路径

当没有适配的 wheel 时,安装过程大致是:

下载 sdist
  │
  ├─解压
  │
  ├─读取其中的 pyproject.toml
  │
  ├─创建构建隔离环境
  │
  ├─安装构建依赖
  │
  ├─调用 build_wheel
  │
  └─安装新生成的 wheel

因此,用户安装 sdist 并不是“直接运行源码”,而是“从发布的源码分发再次构建一个 wheel”。

这解释了一个常见失败:

ERROR: Could not build wheels for demo-package

它可能不是运行时代码错误,而是:

  • sdist 漏了 C 源码;
  • sdist 漏了生成扩展所需的配置;
  • [build-system].requires 不完整;
  • 用户环境缺少系统编译器或系统库;
  • 动态版本计算依赖 Git,但 sdist 中没有 Git 元数据;
  • 构建脚本试图联网下载未打包的依赖。

一个合格的 sdist 应包含构建项目本身所需的文件。下游发行版还可能在没有外网的环境中重新构建,因此依赖的源文件不能只存在于 Git 子模块或构建机器缓存中。(packaging.python.org)

5.3 wheel 与 sdist 的关系

两者不是互相替代的关系:

维度 wheel sdist
主要目标 快速安装 提供源代码并允许重新构建
安装时是否通常需要构建
是否包含编译产物 可以包含 通常不包含最终编译产物
平台相关性 通过 wheel tag 表达 文件本身通常与平台无关
适合生产安装 优先 作为回退或下游构建输入
是否应同时发布 通常是 通常是

只发布 wheel 会让没有匹配平台文件的用户无法安装;只发布 sdist 会把编译器、系统库和构建失败风险转嫁给用户。


六、名称、版本与文件名的三层一致性

6.1 分发名称与导入名称不是一回事

合法的分发名称可以包含字母、数字、.-_,但用于比较和索引查找时会标准化:

import re

def normalize(name: str) -> str:
    return re.sub(r"[-_.]+", "-", name).lower()

因此以下名称在分发层面等价:

Friendly-Bard
friendly.bard
friendly_bard
friendly--bard

它们都会标准化为:

friendly-bard

而 Python 导入名通常必须是合法标识符,例如:

import friendly_bard

这就是为什么项目可以配置:

[project]
name = "friendly-bard"

但源码目录使用:

src/friendly_bard/

索引项目页也使用标准化名称,例如:

/friendly-bard/

名称标准化规则用于比较和查找,不要求展示给用户时抹掉原始名称。(packaging.python.org)

6.2 版本必须满足 PEP 440 规则

公共版本通常遵循:

[N!]N(.N)*[{a|b|rc}N][.postN][.devN]

可以包含:

1.2.0
1.2.0rc1
1.2.0.post1
1.2.0.dev3
1!2.0

版本中的数字按数值而不是字符串比较:

1.10 > 1.9

预发布和开发版本的排序也不是字符串排序:

1.2.0.dev1 < 1.2.0a1 < 1.2.0rc1 < 1.2.0 < 1.2.0.post1

可以用 packaging 验证并比较:

python -m pip install packaging
from packaging.version import Version

versions = [
    "1.2.0.dev1",
    "1.2.0a1",
    "1.2.0rc1",
    "1.2.0",
    "1.2.0.post1",
]

for version in sorted(map(Version, versions)):
    print(version)

预期输出:

1.2.0.dev1
1.2.0a1
1.2.0rc1
1.2.0
1.2.0.post1

版本规范的作用不只是显示版本号,还参与依赖解析。例如:

dependencies = [
    "httpx >=0.27,<1"
]

它表示候选版本必须同时满足:

v0.27v<1v \geq 0.27 \land v < 1

1.0.0rc1 是否参与解析还取决于预发布规则。通常情况下,预发布版本不会被普通版本范围自动选中,除非用户明确请求、当前环境已经有该预发布版本,或者没有其他满足条件的稳定版本。(packaging.python.org)

6.3 项目版本、文件版本和 wheel build tag

需要区分三个概念:

demo_package-1.2.0-2-py3-none-any.whl
                  ^
              build tag
  • 1.2.0 是项目版本,进入 Version 元数据;
  • 2 是 wheel build tag,用于同一项目版本、同一兼容性标签下区分构建文件;
  • py3-none-any 是兼容性标签。

build tag 不是项目的新版本。它不能表达“API 发生了不兼容变化”,也不能替代发布 1.2.1。它适合区分同一项目版本的不同二进制重构建,例如构建环境变化导致需要重新上传 wheel。

实践中,项目版本发布后应尽量保持分发文件不可变。如果发现 wheel 错误,通常应发布新版本,而不是让同名文件内容发生变化。否则索引缓存、镜像、锁文件和哈希校验都会出现冲突。


七、索引:安装工具如何找到文件

7.1 索引不是一个简单的下载目录

Python 包索引至少需要提供:

  1. 项目列表或项目发现能力;
  2. 某个项目的文件列表;
  3. 每个文件的下载地址;
  4. 文件哈希;
  5. 可选的 Requires-Python
  6. 可选的 yanked 状态;
  7. 可选的 Core Metadata;
  8. 可选的来源证明或 attestation。

Simple Repository API 的项目地址形如:

/<normalized-project-name>/

HTML 形式中,每个文件通常由一个 <a> 元素表示:

<a href="/files/demo_package-1.2.0-py3-none-any.whl#sha256=...">
  demo_package-1.2.0-py3-none-any.whl
</a>

JSON 形式中,文件会包含类似字段:

{
  "filename": "demo_package-1.2.0-py3-none-any.whl",
  "url": "https://index.example/files/demo_package-1.2.0-py3-none-any.whl",
  "hashes": {
    "sha256": "..."
  },
  "requires-python": ">=3.14",
  "size": 12345
}

索引中的哈希让安装工具能够验证下载到的字节是否与索引声明一致;requires-python 让安装工具在下载文件前就可以排除不兼容候选。(packaging.python.org)

7.2 候选选择的实际顺序

当执行:

python -m pip install "demo-package>=1.0,<2"

可以把过程概括为:

1. 标准化 demo-package 名称
2. 请求索引的项目页
3. 获取所有版本和分发文件
4. 按版本约束过滤
5. 按 Requires-Python 过滤
6. 对 wheel 计算当前环境支持的标签
7. 丢弃标签不匹配的 wheel
8. 在剩余候选中按安装工具的优先级选择
9. 下载文件
10. 校验哈希
11. 安装 wheel,或从 sdist 构建 wheel

注意,索引提供的是候选集合,最终选择由安装工具完成。索引不会替 pip 决定当前 Python 3.14、Linux x86_64 环境应该选哪一个 wheel。

7.3 yanked 文件不是删除文件

索引可以把某个文件标记为 yanked,并附带原因:

data-yanked="contains a broken dependency declaration"

yanked 的语义是:

  • 普通解析通常忽略它;
  • 如果用户精确锁定该版本,且它是唯一匹配文件,安装工具可能仍使用它;
  • 安装工具应在使用 yanked 文件时发出警告;
  • yanked 状态可以被撤销。

因此,yank 不是“从所有镜像物理删除”,也不是版本撤回的万能机制。它适合处理“文件存在严重问题,但不能破坏已有精确锁定环境”的情况。(packaging.python.org)

7.4 --index-url--extra-index-url

测试索引的安装命令:

python -m pip install \
  --index-url https://test.pypi.org/simple/ \
  demo-package

如果测试包依赖的其他包仍位于正式 PyPI,可以额外指定:

python -m pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  demo-package

但多个索引会扩大候选来源和供应链边界。内部索引、代理索引和公共索引之间的名称覆盖关系需要明确,否则同一个依赖名称可能来自不同来源。TestPyPI 也是独立服务,账号和数据不与正式 PyPI 共用。(packaging.python.org)


八、哈希、GPG 签名与数字 Attestation

标题中的“签名”至少涉及三种不同机制。

8.1 哈希:验证内容是否改变

哈希是函数:

h=H(m)h = H(m)

其中:

  • mm 是文件的完整字节序列;
  • HH 是哈希算法,例如 SHA-256;
  • hh 是固定长度摘要。

下载后重新计算:

python -m pip hash dist/demo_package-1.2.0-py3-none-any.whl

或者:

from hashlib import sha256
from pathlib import Path

path = Path("dist/demo_package-1.2.0-py3-none-any.whl")
digest = sha256(path.read_bytes()).hexdigest()
print(digest)

哈希能回答:

我下载到的文件,是否与已知摘要对应的文件内容一致?

它不能单独回答:

这个摘要是谁发布的?

如果攻击者同时替换文件和索引中的哈希,单纯哈希无法提供发布者身份。

8.2 GPG 签名:文件与密钥身份绑定

传统 GPG 发布方式通常为:

demo_package-1.2.0.tar.gz
demo_package-1.2.0.tar.gz.asc

Simple Repository API 约定,如果某个文件有 GPG 签名,签名文件应与原文件并列,并在文件名后追加 .asc。索引也可以通过 gpg-sig 字段表示签名是否存在。(packaging.python.org)

签名过程可以抽象为:

s=Signprivate key(H(m))s = \operatorname{Sign}_{private\ key}(H(m))

验证过程需要:

  1. 取得文件 mm
  2. 取得签名 ss
  3. 取得并信任发布者公钥;
  4. 验证签名;
  5. 重新计算文件哈希并确认签名对应当前文件。

GPG 的难点不是算法本身,而是公钥分发和信任关系:

  • 公钥从哪里获得?
  • 指纹是否通过独立渠道确认?
  • 发布者私钥是否长期保管?
  • CI 是否需要保存长期私钥?
  • 密钥轮换后如何验证旧版本?

因此,“有 .asc 文件”不等于“已经完成了可信验证”。验证者还必须知道应该信任哪个公钥。

8.3 Attestation:证明构建和发布过程

PyPI 还支持基于 in-toto 的数字 attestation。Attestation 将分发文件名和 SHA-256 摘要绑定到一份签名声明,并可表达:

  • 这个文件由哪个受信任发布身份上传;
  • 源代码来自哪个仓库;
  • 构建过程在哪个 CI 工作流中完成;
  • 构建产物与源码之间的来源关系。

PEP 740 定义的 Attestation v1 使用 X.509 证书、ECDSA P-256 和 SHA-256,并通过 DSSE 对 in-toto Statement 签名。其 subject 中必须包含单个分发文件及其 SHA-256 摘要。(packaging.python.org)

PyPI 的发布证明通常与 Trusted Publishing 和 Sigstore 的无长期密钥模式结合:CI 工作流根据身份获得短期签名能力,而不是把一个长期 GPG 私钥放进仓库 Secrets。(docs.pypi.org)

三者的关系可以这样记:

机制 主要回答的问题
哈希 文件内容有没有变化?
GPG 签名 哪个长期密钥持有者签了这个文件?
Attestation 这个文件由哪个受信任身份、从什么源、通过什么构建流程产生或发布?

它们可以同时使用。Attestation 不会让哈希失去作用;它正是对文件摘要进行身份和来源绑定。


九、从构建到 TestPyPI 的完整示例

9.1 准备项目

src/demo_package/__init__.py

__version__ = "1.2.0"

src/demo_package/cli.py

def main() -> None:
    print("hello from demo-package")

本地直接执行:

python -c "from demo_package.cli import main; main()"

预期输出:

hello from demo-package

这一步验证的是源码结构,不是分发文件结构。

9.2 构建两个分发文件

rm -rf build dist src/*.egg-info
python -m build

预期产物:

dist/demo_package-1.2.0-py3-none-any.whl
dist/demo_package-1.2.0.tar.gz

清理旧目录是为了避免把上一版本的产物一并上传。若不清理,dist/* 可能包含多个版本,导致发布错误。

9.3 检查元数据和文件名

python -m twine check dist/*

预期输出类似:

Checking dist/demo_package-1.2.0-py3-none-any.whl: PASSED
Checking dist/demo_package-1.2.0.tar.gz: PASSED

然后检查 wheel:

python -m zipfile -l \
  dist/demo_package-1.2.0-py3-none-any.whl

检查 sdist:

tar tzf dist/demo_package-1.2.0.tar.gz

重点验证:

sdist 顶层目录 = demo_package-1.2.0/
wheel 文件名版本 = 1.2.0
METADATA 中 Version = 1.2.0
METADATA 中 Requires-Python = >=3.14

如果文件名是 demo_package-1.2.0,但 METADATA 写成 1.2.1,这是发布错误,而不是格式风格问题。文件名承担索引候选发现职责,内部元数据承担安装和依赖解析职责,两者不一致会破坏工具假设。

9.4 分别从 wheel 和 sdist 安装

测试 wheel:

python -m venv /tmp/demo-wheel-env
/tmp/demo-wheel-env/bin/python -m pip install \
  dist/demo_package-1.2.0-py3-none-any.whl

/tmp/demo-wheel-env/bin/demo-hello

预期输出:

hello from demo-package

测试 sdist:

python -m venv /tmp/demo-sdist-env
/tmp/demo-sdist-env/bin/python -m pip install \
  dist/demo_package-1.2.0.tar.gz

/tmp/demo-sdist-env/bin/demo-hello

这两个测试覆盖不同路径:

  • wheel 测试已构建产物能否直接安装;
  • sdist 测试发布的源分发是否足够完整,能否在干净环境中重新构建。

9.5 上传到 TestPyPI

python -m twine upload \
  --repository testpypi \
  dist/*

上传时使用 TestPyPI 的 API token,不应把密码或 token 直接写入命令历史。上传成功后,可以从测试索引安装:

python -m pip install \
  --index-url https://test.pypi.org/simple/ \
  demo-package

TestPyPI 用于验证上传、索引发现、元数据和安装流程;它不是正式 PyPI 的“预发布分支”,两个服务的数据和账号相互独立。(packaging.python.org)


十、入口点:安装文件与命令行命令的连接

示例中的:

[project.scripts]
demo-hello = "demo_package.cli:main"

表示安装后生成一个名为 demo-hello 的命令,该命令调用:

demo_package.cli.main

这里的字符串格式是:

模块路径:对象名

安装工具会根据 wheel 中的入口点元数据创建命令包装器。入口点不是 shell 脚本,也不是在构建阶段把 Python 代码复制成可执行文件;它是“命令名到 Python 对象”的声明。

因此以下错误会在执行命令时暴露:

[project.scripts]
demo-hello = "demo_package.cli:missing"

可能表现为:

ImportError
AttributeError

而不是构建失败。构建阶段只能确认入口点字符串格式可能合法,通常不能保证目标对象真的存在。发布前应在干净虚拟环境中执行生成的命令。


十一、动态版本:方便,但扩大了构建输入

版本可以静态写入:

[project]
version = "1.2.0"

也可以声明为动态:

[project]
name = "demo-package"
dynamic = ["version"]

然后由后端从 Git 标签、模块变量或其他位置计算。

动态版本的核心问题是:版本不再只由 pyproject.toml 决定,而可能依赖:

Git 标签
当前提交
构建时间
环境变量
后端版本

如果开发树中有 Git 标签,构建得到:

demo_package-1.2.0.tar.gz

但从 sdist 构建时没有 Git 仓库,可能得到:

demo_package-0+unknown.tar.gz

这会导致 sdist 与 wheel 的版本不一致,甚至让索引拒绝或错误归类文件。

因此动态版本必须满足一个不变量:

version(sdist)=version(wheel from sdist)\text{version}(\text{sdist}) = \text{version}(\text{wheel from sdist})

如果该条件无法稳定满足,应该把已确定的版本写入发布源树或使用能在 sdist 中保留版本信息的后端机制。Core Metadata 对 sdist 构建出的 wheel 还规定了非动态字段的一致性约束;标记为 Dynamic 的字段才允许在 wheel 构建阶段计算。(packaging.python.org)


十二、常见误解与失败路径

误解一:wheel 就是“编译后的 Python 文件”

不准确。wheel 是安装格式,纯 Python wheel 仍然包含 .py 文件;wheel 规范也不要求包含 .pyc。只有项目包含本地扩展时,wheel 才通常带有 .so.pyd 等编译产物。(packaging.python.org)

误解二:上传了 sdist,用户一定可以安装

不一定。用户安装 sdist 要重新构建 wheel,所以本地必须具备:

  • 正确的 Python 版本;
  • 构建后端及其依赖;
  • 必要的编译器;
  • 必要的系统头文件和库;
  • sdist 中完整的源文件与构建配置。

误解三:Requires-Python 能强制阻止不兼容代码运行

不能。它是元数据约束,安装工具可以据此跳过候选;用户仍然可以直接解压 wheel、绕过安装工具,或者用其他方式运行代码。真正的兼容性仍需要测试和正确的 wheel 标签。

误解四:文件哈希就是签名

不是。哈希没有发布者身份。锁文件中的:

--hash=sha256:...

主要用于确保安装文件字节不被替换;它不说明谁生成了这个文件。

误解五:提高版本的 build tag 就能修复发布错误

build tag 只能区分同一版本下的 wheel 构建文件。若修复了公开行为、依赖声明、源码或 ABI,应根据变更性质发布新的项目版本,而不是把项目版本保持不变并依靠 build tag 隐藏变化。

误解六:删掉索引上的文件就能撤销发布

已被镜像、缓存或用户下载的文件无法通过索引删除追回。yank 的作用是改变后续候选选择,而不是修改已经传播的内容。严重问题应发布修复版本,并在必要时 yank 有问题的文件。(packaging.python.org)


十三、CI 中应验证的不是“命令成功”,而是产物不变量

一个发布流水线至少要验证以下不变量:

1. 项目名称合法,且标准化后唯一
2. sdist 和 wheel 的版本一致
3. sdist 顶层目录和文件名符合规范
4. wheel 标签覆盖预期 Python 3.14 平台
5. wheel 和 sdist 都能在干净环境中安装
6. 安装后的 import 和入口命令可用
7. 依赖元数据与源码实际需求一致
8. 每个上传文件都有 SHA-256
9. 上传身份不依赖长期明文密码
10. 产物上传前已经过测试和审查

可以在 CI 中使用矩阵测试:

strategy:
  matrix:
    python-version: ["3.14"]
    package-kind: ["wheel", "sdist"]

对 wheel,直接安装指定文件;对 sdist,强制从源码分发重新构建。不要只执行:

pip install .

因为这可能绕过“发布的 sdist 是否完整”这一关键路径。

对于包含原生扩展的项目,还应在目标平台分别构建 wheel,并检查生成的标签。例如在 Linux 上生成的 wheel 不能因为“本机构建成功”就推断能在 Windows 或 macOS 上安装。


十四、发布选择的最终判断

可以用下面的决策过程判断一个发布是否完整:

只有纯 Python 代码

通常应发布:

一个 sdist
一个 py3-none-any wheel

wheel 提供快速安装,sdist 提供源码和下游重构建能力。

包含本地扩展

应发布:

一个 sdist
多个与 Python、ABI、操作系统和架构匹配的 wheel

如果某个平台没有 wheel,用户将回退到本地构建;这不是索引错误,而是候选集合中没有满足当前环境标签的构建分发文件。

需要供应链证明

至少保存:

分发文件
SHA-256
构建日志
源代码提交
构建环境
上传身份
Attestation 或其他签名材料

哈希解决文件完整性,签名解决身份和来源绑定,CI 测试解决行为正确性。三者不能互相替代。

最终可以把 Python 包发布理解为一个严格的数据转换:

源树构建后端sdist+wheel索引候选集合安装工具目标环境\text{源树} \xrightarrow{\text{构建后端}} \text{sdist} + \text{wheel} \xrightarrow{\text{索引}} \text{候选集合} \xrightarrow{\text{安装工具}} \text{目标环境}

只要名称、版本、元数据、文件内容、兼容性标签和签名声明之间有一个环节不一致,问题就可能从“上传成功”延迟到用户安装、跨平台构建或安全审计时才暴露。工程上真正可靠的发布,不是生成两个文件,而是让这条转换链在干净环境、不同平台和可验证身份下保持一致。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。