Day 181 — 原理图解
五张图,从「版本号怎么比大小」到「一个文件怎么变成 PyPI 上的一行」。
图 1 · 语义化版本的优先级阶梯
优先级 低 ───────────────────────────────────────────────────▶ 高
1.0.0-alpha
│ 数字段按数值比,字母段按字典比
│ 前缀相同的,段数少的更小
▼
1.0.0-alpha.1
▼
1.0.0-alpha.beta
▼
1.0.0-beta
▼
1.0.0-beta.2
▼
1.0.0-beta.11 ← 注意:11 > 2(数值比较),不是字典序
▼
1.0.0-rc.1
▼
1.0.0 ← 正式版永远高于自己的任何预发布
┌────────────────────────────────────────────┐
│ 1.0.0+build.1 == 1.0.0+build.999 │
│ 构建元数据不参与比较(规范第 10 条) │
└────────────────────────────────────────────┘
一句话:预发布是「还没好」,+build 只是备注。比较时只看前三段 + 预发布。
图 2 · 六步发布链路(哪一步是不可逆的)
① 打 tag git tag v1.2.3
│
▼
② 校验一致性 tag 版本 == pyproject version ?
│ ✗ 不一致 → 就此终止(连构建都不做)
▼
③ 构建 python -m build → dist/*.whl + dist/*.tar.gz
│
▼
④ 校验产物 文件名 / METADATA / wheel 与 sdist 版本一致 / twine check
│ ✗ 任一失败 → 就此终止
▼
⑤ 上传 POST /legacy/ (multipart + Basic __token__)
│
═════════════════════════════ ← 分水线:从这里开始不可逆
│
▼
⑥ 索引生效 PyPI 页面 / JSON API / pip install
为什么第 ⑤ 步是分水线:
PyPI 不允许覆盖同名文件 → 传错的 1.2.3 收不回来
→ 只能在 1.2.4 里修 → 所以校验必须全部发生在 ⑤ 之前
一句话:流水线的形状不是随便定的,是被「不可逆」这个约束逼出来的。
图 3 · 前端、后端与两种产物
源码(pyproject.toml + 包目录)
│
│ 谁来执行构建?
▼
┌───────────────────────────────────────────────┐
│ python -m build ← PEP 517 前端 │
│ 1. 建隔离环境(可选,--no-isolation 可跳) │
│ 2. 装 build-system.requires 里的后端 │
│ 3. 调用后端的钩子 │
└───────────────────┬───────────────────────────┘
│ 调
▼
┌───────────────────────────────────────────────┐
│ setuptools.build_meta ← PEP 517 后端 │
│ build_sdist() build_wheel() │
│ │
│ ★ 前端不在时,本日脚本直接调这里(降级路径) │
│ 实测:后端直调 513 ms vs 前端 1921 ms │
└───────────────────┬───────────────────────────┘
│
┌───────────┴────────────┐
▼ ▼
demo_release_pkg-1.2.3.tar.gz demo_release_pkg-1.2.3-py3-none-any.whl
(sdist:源码 + 构建配置) (wheel:已构建,装了就能用)
安装时需现场构建 安装时直接解压
实测 1222 字节(浮动 ±20) 实测 1458 字节(稳定)
注意:文件名是可解析的元数据
{归一化名}-{版本}(-{build})?-{python}-{abi}-{platform}.whl (PEP 427)
{归一化名}-{版本}.tar.gz (PEP 625)
归一化 = 小写 + 连续 - _ . 压成单个 - (PEP 503)
一句话:前端是壳,后端是干活的;产物文件名本身就是契约的一部分。
图 4 · 一条提交怎么变成 CHANGELOG 的一行
git 提交(唯一的「人写」环节)
┌──────────────────────────────────────────────────────┐
│ 8b7a6f5 feat(cli)!: 把 --output 参数改名为 --format │
│ │
│ BREAKING CHANGE: --output 已移除,请改用 --format │
└──────────────────────────────────────────────────────┘
│
│ ① 按行首模式切分(7-40 位十六进制 sha + 主题)
│ ★ 不能按空行切:git log 在提交之间不加空行
▼
┌───────────────────────────────────────────────┐
│ type=feat scope=cli bang=! (正文另有 BREAKING)│
└───────────────┬───────────────────────────────┘
│
┌───────────┴────────────┐
▼ ▼
② 分类 ③ 版本决策
breaking 优先 → Changed 出现过 breaking → MAJOR +1
feat → Added 否则有 feat → MINOR +1
fix → Fixed 否则 → PATCH +1
docs/chore/ci → 丢弃 (0.x 项目另按惯例)
│ │
└───────────┬────────────┘
▼
④ 渲染
## [2.0.0] - 2026-10-16
### BREAKING CHANGES
- 把 --output 参数改名为 --format (8b7a6f5)
### Added
- **api**: 新增分页参数 page/page_size (a1b2c3d)
### Changed / ### Fixed …
[2.0.0]: https://…/compare/v1.1.4...v2.0.0 ← compare 链接
实测:7 条提交 → 跳过 2 条(docs/chore)→ 1.1.4 决策为 2.0.0
一句话:提交守规矩,CHANGELOG 和版本号就都能算出来;! 与
BREAKING CHANGE: 两种写法都要认。
图 5 · 上传协议时序(客户端 ↔ 索引)
客户端(twine / 本日 04 脚本) 索引(PyPI / 本地桩)
│ │
│ ① POST /legacy/ │
│ Content-Type: multipart/form-data │
│ Authorization: Basic │
│ base64("__token__:pypi-****") │
├─────────────────────────────────────────▶│
│ │ ② 认证头解析
│ │ user=__token__ ?
│ │ secret 以 pypi- 开头 ?
│ │ ✗ → 403(实测拿到过)
│ │
│ multipart 字段: │ ③ 解析 multipart
│ :action=file_upload │ 取 metadata / content
│ protocol_version=1 │
│ metadata=<core metadata 文本> │ ④ 算 sha256(content)
│ sha256_digest=<摘要> │ 与 sha256_digest 比
│ content=<文件字节> │ ✗ → 400(桩实测过)
│ │
│ ⑤ 200 {"message": "OK"} │ ⑤ 落库 + 生成索引条目
│◀─────────────────────────────────────────│
│ │
│ ⑥ GET /pypi/<name>/json ← 发布后校验 │
│ 查文件名、size、sha256 对不对 │
├─────────────────────────────────────────▶│
│ │
实测边界:假 token → 403;本地桩篡改摘要 → 400;本地桩正常上传 → 200
未实测:真实 token 的成功上传(见 README 实验 6)
一句话:上传不是「发个文件就完了」——认证、完整性、协议字段三关, 任何一关不过都必须在「不可逆」之前暴露出来。
一句话速记
| 图 | 一句话 |
|---|---|
| 1 | 预发布低于正式版;+build 不参与比较;数字段按数值比 |
| 2 | 校验必须全部在「上传」之前,因为上传不可逆 |
| 3 | build 是前端、setuptools 是后端;文件名就是元数据 |
| 4 | 提交守规矩 → CHANGELOG 与版本号都能自动推导 |
| 5 | 认证 → 完整性 → 落库,三关都在不可逆点之前 |