打包与发布:wheel / pyproject / uv
第 8 章里我们写了 pyproject.toml,但只用它记了依赖。这一章把"打包发布"整条链路走完:模块和包和发行版到底谁是谁、pyproject.toml 里每一项在说什么、python -m build 产出的两个文件分别干什么用、wheel 名字里那串 cp312-cp312-manylinux_x86_64 怎么读、以及现在为什么越来越多人用 uv 管依赖。
模块、包、发行版:三个词别再混用
是什么:这三个词在中文里常被笼统叫"包",但它们在 Python 里是完全不同的东西。
| 术语 | 英文 | 是什么 | 例子 |
|---|---|---|---|
| 模块 | module | 一个 .py 文件,import 的最小单位 | utils.py |
| 包 | package | 一个含 __init__.py 的目录(命名空间包可以没有) | myapp/ |
| 发行版 | distribution(dist) | 发布到 index 上的一个可安装单元,含元数据 | requests 2.32.3 |
| 导入包名 | import package | 代码里 import xxx 写的名字 | sklearn |
| 发行版名 | distribution name | pip 安装时写的名字(可与导入名不同) | scikit-learn |
为什么必须分清:因为最经典的困惑"pip install 什么名字才能 import 到它",答案就在这张表里。Pillow 是发行版名,PIL 是导入包名;beautifulsoup4 装完要 import bs4;scikit-learn 装完要 import sklearn。发行版名允许有连字符和点,导入包名不允许——所以两者在命名上必然容易错位。不确定时查 PyPI 页面右侧的 Meta 信息,或 pip show -f 包名 看文件列表。
写 import scikit-learn 会直接 SyntaxError(连字符在标识符里非法);写 import scikit_learn 会 ModuleNotFoundError。这类错误发生在刚装完依赖、信心最足的时刻,很容易怀疑"是不是没装上"。判断顺序:① 先 pip show 包名 确认已安装;② 用 pip show -f 包名 看文件清单确定真实导入名;③ 再 import。顺手记住三个常踩的:PyYAML 对应 yaml、python-dateutil 对应 dateutil、opencv-python 对应 cv2。
pyproject.toml(PEP 621):现代项目的标准入口
是什么:pyproject.toml 是 2016 年 PEP 518 引入的"项目配置文件的唯一入口",2020 年 PEP 621 又统一了元数据字段的写法。现在一个项目的元数据应该只在这里声明,不再需要 setup.py 里的 setup(...) 调用。
为什么需要它:以前元数据散落在 setup.py、setup.cfg、requirements.txt、tox.ini、.flake8 里,每加一个工具就多一个配置文件。而且 setup.py 是可执行的 Python 代码——这意味着 pip install 时要运行你的代码才能知道依赖是什么,安全上是个缺口(历史上有恶意包在 setup.py 里执行任意命令)。把元数据变成声明式的 TOML,工具就能静态读取。
论为什么库的依赖要写上界,"固定版本"反而是坏习惯
① 库(给别人用):写 pandas>=2.0, <3.0。下界保证用到的 API 存在,上界挡住未来大版本的不兼容变更。② 应用(部署上线):用锁文件把版本钉死到补丁号(pandas==2.2.3),因为你的服务只有一个确定环境,不需要兼容性区间。③ 为什么不要在库的 dependencies 里写 ==:会把冲突传染给所有使用者——用户项目里另一个库也要 numpy,两个都被钉死不同版本时就是"依赖地狱"。④ requires-python 很值钱:pip 会在解析阶段直接拒绝装到不支持的 Python 上,比用户装完跑起来报 SyntaxError 友好一万倍。
build backend 怎么选:hatchling / setuptools / poetry-core
是什么:build backend 是真正"把源码变成 wheel / sdist"的程序。pyproject.toml 只声明元数据,具体怎么打包、怎么找包、怎么处理 include/exclude,由 backend 决定。
| backend | 特点 | 选它的理由 |
|---|---|---|
| hatchling | 现代、纯 Python、不依赖 setup.py | 新项目推荐默认;配置简洁,支持多环境 |
| setuptools | 历史悠久、生态最大 | 需要 C 扩展,或维护老项目时最稳 |
| poetry-core | 与 Poetry 工作流绑定 | 团队已经在用 Poetry 管依赖与虚拟环境 |
| flit-core | 极简,只做纯 Python 包 | 单文件小工具,配置最少 |
| maturin | Rust / C 扩展专用 | 写 pyo3 扩展时用(见第 16 章) |
| scikit-build-core | CMake 驱动 | C++ 大型项目、已有 CMake 构建 |
论为什么推荐 src layout
① 逼你真装一次:flat layout 下,你在仓库根目录运行 python,当前目录就在 sys.path 里,import my_super_tool 直接成功——哪怕你的打包配置是错的。等你发到 PyPI 才发现"用户装上导不进来"。src layout 让源码不在根目录,你必须先 pip install -e . 才能导入,于是打包问题会在开发阶段暴露。② 顺带解决测试污染:测试里 import 到的是"安装后的包",和用户拿到的完全一致。③ 代价:多一层目录,且必须配 [tool.hatch.build.targets.wheel] packages(或 setuptools 的 package-dir)。这点成本完全值得。
dist/ 里的旧 wheel 不会自动删。python -m build 会把新旧文件混在一起,你 twine upload dist/* 时可能把上一次构建的旧包也传上去,版本号还是旧的,PyPI 报"该版本已存在"——然后你以为发布失败,其实成功了一半。习惯:构建前 rm -rf dist/ build/ *.egg-info。另外 [project] version 手写很容易忘记改,正式项目建议用 hatch-vcs 或 setuptools-scm 从 Git tag 自动生成版本号。
python -m build:sdist 和 wheel 分别是什么
是什么:python -m build 会在隔离环境里调用你的 build backend,产出两个文件到 dist/:一个 .tar.gz(sdist,源码分发包)和一个 .whl(wheel,二进制分发包)。
| 产物 | 全称 | 里面是什么 | 什么时候被用到 |
|---|---|---|---|
.tar.gz | sdist(source distribution) | 源码 + pyproject.toml + 构建脚本 | 没有匹配 wheel 时,pip 现场构建 |
.whl | wheel | 已经打好的、可直接解压到 site-packages 的包 | 首选,装得快、无需构建 |
wheel 装得快是因为"东西都已经在里面了";sdist 是"在用户机器上现场构建",所以构建所需的一切都必须在 sdist 里。常见翻车:只把 src/ 打进 wheel,忘了 sdist 还需要 README.md、LICENSE,可能还有 MANIFEST.in 或模板文件——于是维护者本地一切正常,用户 pip install(恰好没匹配 wheel)时构建失败。自查方法:tar tzf dist/x.tar.gz 看清单,或直接 pip install dist/x.tar.gz 装一遍。另一条铁律:构建必须在隔离环境里做(python -m build 默认如此),别依赖你本地恰好装了什么。
读懂 wheel 文件名:cp312-cp312-manylinux_x86_64
是什么:wheel 文件名不是随便起的,它是"兼容性标签",格式固定为四个部分用连字符连接。
| 标签 | 含义 | 例子怎么读 |
|---|---|---|
py3 / cp312 | python 标签:能跑在哪个解释器 | cp312 是 CPython 3.12;py3 是任意 Python 3 |
none / cp312 | ABI 标签:是否绑定 C API 版本 | none 是纯 Python;cp312 用了 3.12 的 C ABI |
any | 平台无关 | 纯 Python 包用这个 |
manylinux_2_17_x86_64 | Linux x86-64,glibc 至少 2.17 | 2_17 是 glibc 版本,数字越小兼容越广 |
musllinux_1_2 | Alpine(musl libc)专用 | Alpine 容器里必须装这个变体 |
win_amd64 / macosx_11_0_arm64 | Windows / macOS 平台 | Mac M 系列是 arm64 |
论为什么 pip 不用你选,它自己"挑一个"
① 匹配规则:pip 拿到你的解释器信息(如 CPython 3.12 / Linux x86-64 / glibc 2.31),从 PyPI 上这个包的所有 wheel 里筛出标签兼容的那些,取"最专用"的那个(专用优先于通用,因为专用通常编译时做了平台优化)。② 全都匹配不上时:pip 回退到下载 sdist 现场编译——这就是为什么同一个包在 Mac 上装 3 秒、在某个老 Linux 上要装 5 分钟还可能失败。③ 纯 Python 包为什么这么香:py3-none-any 一个文件对所有平台都有效,发布者只需上传一次。④ 一个实用结论:当你看到 pip install 明显变慢或在装编译器,八成是没匹配到 wheel,可以用 pip install --only-binary :all: 强制要求二进制包,让它明确报错而不是偷偷去编译。
带 C 扩展的包(NumPy、Pillow、lxml)在 PyPI 上有几十个 wheel 覆盖各平台,这是维护者用 CI 构建出来的。你自己写的 C 扩展如果只在自己机器上 python -m build,产出的 wheel 平台标签是你本机的(比如 macosx_14_0_arm64)——Linux 用户根本装不上,只能退回源码编译,没有编译环境就直接失败。正确做法见第 16 章:用 cibuildwheel 在 CI 里构建全部平台。纯 Python 项目则应该主动打成 py3-none-any(不含 C 代码时 backend 会自动这么做),别让用户白等编译。
发布到 PyPI 与私有 index
是什么:三种发布目的地——公共 PyPI(全世界可装)、TestPyPI(练习用,会定期清空)、私有 index(公司内部,Nexus / Artifactory / devpi / GitHub Packages)。
同一个版本号只能上传一次,传上去就不能删改(只能 yank),而且已经装过它的用户不会自动回滚。所以:① 永远先在 TestPyPI 演练完整流程,包括在干净 venv 里装一遍;② 用 twine check dist/* 校验 README 渲染和元数据;③ 版本号一旦发出去就当它不能撤回,出问题只能发 0.3.2。万一误传了含密钥的包:即使能删,文件也可能已被镜像和缓存,密钥必须立刻轮换,不要心存侥幸。
依赖锁定:requirements.txt / poetry.lock / uv.lock
是什么:"声明依赖"和"锁定依赖"是两件事。声明是"我需要 pandas>=2.0";锁定是"这次部署用 pandas==2.2.3,连同它间接依赖的 numpy==2.1.3、python-dateutil==2.9.0 一起钉死"。
为什么需要锁定:"这个项目昨天还能跑,今天 pip install 之后就崩了"——你没改代码,是某个间接依赖发了新版本。锁文件把这些版本固化成可复现的清单,让"本地能跑"和"线上能跑"之间的差距消失。
| 工具 | 声明文件 | 锁文件 | 特点 |
|---|---|---|---|
| pip + pip-tools | requirements.in | requirements.txt(pip-compile 生成) | 最传统,几乎所有环境都支持 |
| Poetry | pyproject.toml | poetry.lock | 声明、锁定、构建、虚拟环境一体 |
| Pipenv | Pipfile | Pipfile.lock | 热度下降,新项目不推荐 |
| uv | pyproject.toml | uv.lock | Rust 实现,解析和安装极快 |
| conda | environment.yml | conda-lock | 数据科学场景,含非 Python 依赖 |
论uv 快在哪,值不值得换
① 语言决定的下限:uv 是 Rust 写的,依赖解析、下载、解包、装 wheel 全并行,同一套依赖的"冷启动安装"常比 pip 快一个数量级以上。② 解析算法也不同:pip 在复杂约束下(十几个包互相要求不同版本的 numpy)可能反复回溯甚至放弃;uv 的解析器在这类场景明显更稳。③ 一个工具覆盖多件事:uv 同时扮演虚拟环境管理、依赖解析、锁文件、构建后端、Python 版本安装(uv python install 3.12)的角色,团队不用再记 pip + virtualenv + pip-tools + pyenv 四套命令。④ 什么时候先别换:公司 CI 镜像、内网源、私有构建流程都是按 pip 写的,迁移成本可能大于收益;生产环境"能用且稳定"比"快"重要。⑤ 折中路线(推荐):本地开发用 uv 提速,同时 uv export 出一份 requirements.txt 给现有 CI 用,两边都能跑,迁移不紧张。
① venv 绝对不能进 Git:虚拟环境里有几千个文件、几十 MB,还包含本机绝对路径(activate 里写死了 /Users/you/...),别人 clone 下来根本不能用。.gitignore 里写 .venv/ venv/ __pycache__/ *.egg-info/ dist/ build/ .env。② pip freeze 不是锁文件:它把当前环境里所有包都写进去,包括你为了调试随手装的、或者被别的工具带进来的无关包;环境被污染过就不干净。要锁依赖,用 pip-compile 从声明的 .in 文件生成,或者用 uv / Poetry 的锁。③ 锁文件必须进 Git:它体积小、是文本,正是为了可复现才提交的。
① 模块 / 包 / 发行版三件事:.py 是模块,带 __init__.py 的目录是包,PyPI 上的可安装单元是发行版;发行版名可以和导入名不同(scikit-learn / sklearn)。
② pyproject.toml 是现代项目的唯一配置入口(PEP 621);库的依赖写下界加下界之上的上界,应用的依赖用锁文件钉死到补丁号。
③ backend 选型:纯 Python 用 hatchling,要 C 扩展用 setuptools,Rust 扩展用 maturin;推荐 src layout,让打包错误在开发期暴露。
④ 两种产物:sdist 是源码(可能现场编译),wheel 是现成品(首选);py3-none-any 是纯 Python 的万能 wheel,带 C 扩展的必须在 CI 里用 cibuildwheel 构建全平台。
⑤ 发布:先 TestPyPI 演练,twine check 校验,用 API token 或 Trusted Publishing;版本号一旦上传不可覆盖。
⑥ 锁依赖:requirements.txt / poetry.lock / uv.lock 都是"可复现"的手段,必须提交;venv 绝不能进 Git。
1.(概念题)pip install scikit-learn 之后为什么不能 import scikit-learn?这类困惑怎么排查?
查看答案
答案:scikit-learn 是发行版名,导入包名是 sklearn;而且连字符在 Python 标识符里非法,写出来就是语法错误。排查顺序:pip show 包名 确认装了,pip show -f 包名 看文件清单确定真实导入名,再 import。类似的还有 Pillow 对应 PIL、beautifulsoup4 对应 bs4、opencv-python 对应 cv2。
2.(概念题)sdist 和 wheel 有什么区别?为什么 pip 优先装 wheel?
查看答案
答案:sdist 是源码包,用户端需要现场构建;wheel 是已经构建好的产物,解压即用。pip 优先 wheel 是因为安装快、不需要编译工具链、结果可复现。只有匹配不到 wheel 时才会回退到 sdist 编译,这也是"某台机器装包特别慢"的常见原因。
3.(概念题)读一下这个文件名:numpy-2.1.3-cp312-cp312-manylinux_2_17_x86_64.whl。
查看答案
答案:发行版 numpy,版本 2.1.3;python 标签 cp312 表示面向 CPython 3.12;abi 标签 cp312 表示绑定了 CPython 3.12 的 C ABI;平台标签 manylinux_2_17_x86_64 表示 Linux x86-64 且 glibc 至少 2.17。它不能装到 Python 3.11,也不能装在 musl(Alpine)系统上。
4.(概念题)为什么说"库的依赖写上界、应用的依赖钉死版本"?
查看答案
答案:库要被别人依赖,写 == 会把冲突传染给使用者(依赖地狱),所以写下界保证 API 存在、写上界挡住大版本破坏性变更。应用只有自己一个确定环境,用锁文件把直接与间接依赖都钉死到补丁号,才能保证"本地能跑等于线上能跑"。
5.(思考题)同事说"我本地 python -m build 出的 wheel 传上去了,结果 Linux 用户都说装不上"。你怎么解释?
查看答案
答案:他在 macOS/ARM 上构建,产出的 wheel 平台标签是他本机的(如 macosx_14_0_arm64),Linux 机器标签不匹配,pip 只能回退去编 sdist,没有编译环境就失败。纯 Python 包不会有这问题(标签是 py3-none-any)。带 C 或 Rust 扩展的包必须在 CI 里用 cibuildwheel(或 maturin 的 manylinux 构建)产出全平台 wheel 再发布。