周三晚上 11 点,你在某 私募 量化组里维护着一个内部小包 xyzprice:封装 涨跌停 价计算、复权因子拼接、T+1 持仓核算这三件每周都要做的事。组里另外两位 PM 想直接 pip install xyzprice 就能用,而不是每次复制粘贴你脚本里那几个函数。pyproject.toml 已经写好,pytest 测试全绿——下一步只剩四件事:构建产物、推上 PyPI、写一份变更日志、再打一个 Git 标签把这一刻钉在历史上。
两种分发:sdist 与 wheel
Python 包有两种标准产物。源代码分发(sdist)是一个 .tar.gz,里面装着你的源代码与 pyproject.toml,下载到任何平台都能从头重新构建;wheel 是一个 .whl(本质是 zip),把 Python 代码加 *.dist-info/ 元数据目录预先打包好,安装时直接解压到 site-packages,无需任何构建步骤。
纯 Python 项目两份产物都是几秒钟的事;但只要包里带 C 扩展或 Rust 扩展,wheel 就是性能关键路径——它把编译这一步从用户机器挪到了你的 CI 上。本课全程只造 py3-none-any 的纯 Python wheel,跨平台编译的故事留给 3.5.4 与 3.6.5。
构建命令两行就够:
pip install build
python -m build
跑完后 dist/ 目录里会出现两个文件:dist/xyzprice-0.1.0.tar.gz 与 dist/xyzprice-0.1.0-py3-none-any.whl。wheel 文件名按 PEP 427 拼接——包名、版本 0.1.0、Python 标签 py3(任意 Python 3 解释器都能跑)、ABI 标签 none(不依赖具体的 CPython ABI)、平台标签 any(任何 OS 与 CPU 架构)。三个 none / any 共同声明「这是一个纯 Python 包」。等价的更短一行是 uv build,产物同样落在 dist/。
语义化版本:MAJOR.MINOR.PATCH
事实标准是 SemVer 2.0:MAJOR.MINOR.PATCH,规则只有三条:
MAJOR加 1:你做了破坏性变更(breaking change),现有用户必须改自己的代码。MINOR加 1:你新增了向后兼容的功能(backward-compatible feature),老调用照样工作。PATCH加 1:你只是修了 bug(backward-compatible bug fix),行为更贴近文档承诺。
社区还有一条惯例:0.x.y 阶段(pre-1.0)「一切皆可变」,破坏性变更允许只升 MINOR;打出第一个 1.0.0 之后才严格执行三条规则。CalVer(按日期编号)与 ZeroVer(永远 0.x.y)是两种替代方案,但 Python 库的主流仍是 SemVer。
三个小 diff 选号试手:
- 「修掉 涨跌停 价计算的 off-by-one」——行为更贴近文档、老调用不变 → PATCH。
- 「为 科创板 代码(688 开头)加支持」——新增能力、老代码原样跑 → MINOR。
- 「把公开函数
mean_price重命名为vwap」——任何老调用都会AttributeError→ MAJOR。
发布到 TestPyPI 与 PyPI
正式推 PyPI 之前,先在 TestPyPI(test.pypi.org)走一遍——它是 PyPI 的沙箱镜像,账号体系独立,可放心试错。
pip install twine
twine upload --repository testpypi dist/*
pip install -i https://test.pypi.org/simple/ xyzprice
第三条用 -i 强制 pip 走 TestPyPI 索引,验证别人确实能装上你的包。验证通过后,把 --repository testpypi 去掉重跑一次 twine upload,就是正式 PyPI 发布。PyPI 与 TestPyPI 都是全球公共服务,国内 可直接访问,只是上传速度受跨境链路影响——建议在稳定网络下完成 twine upload。企业内网常额外架一层私有索引(devpi、Artifactory、AWS CodeArtifact 等),具体接入留给 3.6.5。
PyPI 现在主推受信任发布者(trusted publisher):让 GitHub Actions 这样的 CI 平台用 OIDC 直接向 PyPI 证明「我就是 xyzprice 项目授权过的 workflow」,从此不必在 CI Secrets 里塞一条长期有效的 API token——后者一旦泄露就是全包接管。本课只点名这件事的存在;具体接线步骤在 3.6.5 配合 CI 一起讲。
变更日志与 Git 标签
每次发版前,把改动写进 CHANGELOG.md。社区事实标准是 Keep a Changelog,骨架如下:
# Changelog
## [Unreleased]
## [0.2.0] - 2026-05-24
### Added
- 科创板(688xxx)代码的 涨跌停 价计算支持
### Changed
### Fixed
### Removed
## [Unreleased] 永远在顶部,平时未发版的改动先堆在这里;切版本时把它下面的内容剪到一个新的 ## [0.2.0] - 2026-05-24 标题里就完成了一次发版记录。四个固定小节 Added / Changed / Fixed / Removed 覆盖了 99% 的改动语义。
随后给这一刻打 Git 标签:
git tag -a v0.2.0 -m "Release 0.2.0"
git push origin v0.2.0
-a 创建带说明的 annotated tag(而非轻量 lightweight tag),-m 写释义;push origin v0.2.0 把标签推到远端。约定上,Git 标签带前缀 v(v0.2.0)、pyproject.toml 里的 version 字段不带(0.2.0),视觉上区分「这是一个 tag」还是「这是一个版本号」。
动态版本号的三种来源
pyproject.toml 的 version 字段有三种常见写法:
- 字面量:
version = "0.1.0"直接写死,最简单也最透明。 - 从代码读:
version = {attr = "xyzprice.__version__"}加上dynamic = ["version"],构建后端会去读源码里那一行__version__,避免 toml 与代码不一致。 - 从 Git 标签读:用
hatch-vcs或setuptools-scm插件,构建时读最近一个v*标签——好处是「打 tag 就等于发版」,代价是无 tag 的 dev 构建文件名会很丑。
对小项目,默认选第一种。等到流程长出 CI 自动化、需要单一事实来源(single source of truth)时再切到方案 2 或 3,复杂度与收益才匹配得上。配合 towncrier 这类自动变更日志生成器,是更后面的故事。
练习
Exercise
你维护一个版本号为 1.4.2 的包。对下面每一项改动,从 1.4.3 / 1.5.0 / 2.0.0 三个候选里挑出下一个语义化版本,并用一句话给出理由:
(a) 你修了一个 bug——之前 mean_price([]) 会返回 0.0,现在改成抛 ValueError。
(b) 你新增了一个公开函数 vwap(ticks),既有行为完全不变。
(c) 你把公开函数 mean_price 重命名为 arithmetic_mean_price,没有保留别名。
然后写出发布 (b) 这一版的 git tag -a 命令,以及一段 Keep-a-Changelog 形式的 ## [<version>] - 2026-05-24 标题加一条 ### Added 下的项目符号。
提示
按「老用户是否被迫改代码」分类:被迫改 = MAJOR;没被迫改、但能用到新东西 = MINOR;行为更贴近文档承诺 = PATCH。三个 case 各落一档。
提示
(a) bug 修正,PATCH 升 1.4.3;(b) 新增公开函数,MINOR 升 1.5.0;(c) 无别名重命名属于破坏性变更,MAJOR 升 2.0.0。tag 写 git tag -a v1.5.0;标题写 ## [1.5.0] - 2026-05-24。
下一步
从 pyproject.toml 起步、经 pytest 测试、覆盖率与 hypothesis 加固,再到本课的构建—发版—标签,你已经能让组里另一个人用一行 pip install xyzprice 装上你写的包。下一阶段 Subject 3.2(Python for Data & Quant)把这条「能装上」的链路扩成「扛得住量化日常」的工具链——numpy/pandas/polars 的列存 I/O、pyarrow 的零拷贝交换、把回测脚本写成可发版库的标准布局。CI 自动化(pre-commit、tag push 触发的自动构建与发布)与容器化部署留给 3.6.5;此时此刻,你已经完成了 Python 工程化的第一程。