楼层: 首页/ 软件技术/ Python 基础/ 打包与发布:wheel / pyproject / uv
14

打包与发布:wheel / pyproject / uv

Packaging & Publishing

第 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 namepip 安装时写的名字(可与导入名不同)scikit-learn

为什么必须分清:因为最经典的困惑"pip install 什么名字才能 import 到它",答案就在这张表里。Pillow 是发行版名,PIL 是导入包名;beautifulsoup4 装完要 import bs4;scikit-learn 装完要 import sklearn。发行版名允许有连字符和点,导入包名不允许——所以两者在命名上必然容易错位。不确定时查 PyPI 页面右侧的 Meta 信息,或 pip show -f 包名 看文件列表。

别把发行版名当成导入名去 import

写 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,工具就能静态读取。

# pyproject.toml —— PEP 621 标准写法 [build-system] requires = ["hatchling>=1.21"] # 构建这个项目需要什么 build-backend = "hatchling.build" # 用哪个后端来构建 [project] name = "my-super-tool" # 发行版名(pip install my-super-tool) version = "0.3.1" description = "一个把 CSV 转成报表的小工具" readme = "README.md" requires-python = ">=3.10" # 低于这个版本直接拒绝安装 license = { text = "MIT" } authors = [{ name = "张三", email = "zhangsan@example.com" }] keywords = ["csv", "report"] classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", ] dependencies = [ # 运行时依赖,装你的包会自动带上 "click>=8.1", "pandas>=2.0, <3.0", # 上界也要写,防大版本破坏性变更 ] [project.optional-dependencies] # 可选依赖:pip install my-super-tool[dev] dev = ["pytest>=8", "ruff>=0.5", "mypy>=1.10"] [project.urls] Homepage = "https://github.com/me/my-super-tool" Issues = "https://github.com/me/my-super-tool/issues" [project.scripts] # 安装后生成命令行入口 my-tool = "my_super_tool.cli:main" # 命令名 = 模块:函数 [tool.hatch.build.targets.wheel] packages = ["src/my_super_tool"] [tool.ruff] line-length = 100 [tool.pytest.ini_options] testpaths = ["tests"]

论为什么库的依赖要写上界,"固定版本"反而是坏习惯

① 库(给别人用):写 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 包单文件小工具,配置最少
maturinRust / C 扩展专用写 pyo3 扩展时用(见第 16 章)
scikit-build-coreCMake 驱动C++ 大型项目、已有 CMake 构建
# 目录结构:src layout(推荐) my-super-tool/ ├── pyproject.toml ├── README.md ├── LICENSE ├── src/ │ └── my_super_tool/ │ ├── __init__.py # 通常只放 __version__ │ ├── cli.py │ └── report.py └── tests/ └── test_report.py # flat layout(老项目常见):包直接放仓库根目录 my-super-tool/ ├── pyproject.toml ├── my_super_tool/ └── tests/

论为什么推荐 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)。这点成本完全值得。

改了 build backend 却忘了清 dist/

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.gzsdist(source distribution)源码 + pyproject.toml + 构建脚本没有匹配 wheel 时,pip 现场构建
.whlwheel已经打好的、可直接解压到 site-packages 的包首选,装得快、无需构建
$ pip install build $ python -m build # 同时产出 sdist 和 wheel $ python -m build --wheel # 只要 wheel $ python -m build --sdist # 只要 sdist # 产出 dist/my_super_tool-0.3.1.tar.gz dist/my_super_tool-0.3.1-py3-none-any.whl
# 发布前必做:在干净环境里试装,别在自己的开发环境里自欺欺人 $ python -m venv /tmp/clean && source /tmp/clean/bin/activate $ pip install dist/my_super_tool-0.3.1-py3-none-any.whl $ cd /tmp && python -c "import my_super_tool; print(my_super_tool.__version__)" $ my-tool --help # 命令行入口能跑吗
# 检查 wheel 内容:wheel 本质是个 zip $ unzip -l dist/my_super_tool-0.3.1-py3-none-any.whl my_super_tool/__init__.py my_super_tool/cli.py my_super_tool-0.3.1.dist-info/METADATA # 依赖、版本、作者 my_super_tool-0.3.1.dist-info/entry_points.txt # 命令行入口 my_super_tool-0.3.1.dist-info/RECORD # 文件与哈希清单 # 验证元数据写对没有 $ python -m zipfile -e dist/*.whl /tmp/x && cat /tmp/x/*.dist-info/METADATA
sdist 里少文件,用户装了就报"找不到模块"

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 文件名不是随便起的,它是"兼容性标签",格式固定为四个部分用连字符连接。

# {发行版名}-{版本}-{python标签}-{abi标签}-{平台标签}.whl numpy-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl # ├─ 名字 ─┘ ├版本┘ ├Python┘ ├ ABI ─┘ └──── 平台 ────┘ my_super_tool-0.3.1-py3-none-any.whl # py3 = 任何 Python 3;none = 不依赖 ABI;any = 任何操作系统 # ← 纯 Python 包的"万能 wheel",一个文件走天下
标签含义例子怎么读
py3 / cp312python 标签:能跑在哪个解释器cp312 是 CPython 3.12;py3 是任意 Python 3
none / cp312ABI 标签:是否绑定 C API 版本none 是纯 Python;cp312 用了 3.12 的 C ABI
any平台无关纯 Python 包用这个
manylinux_2_17_x86_64Linux x86-64,glibc 至少 2.172_17 是 glibc 版本,数字越小兼容越广
musllinux_1_2Alpine(musl libc)专用Alpine 容器里必须装这个变体
win_amd64 / macosx_11_0_arm64Windows / 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)。

# 1. 先传到 TestPyPI 练一遍,别拿正式版本号做实验 $ pip install twine $ twine upload --repository testpypi dist/* $ pip install --index-url https://test.pypi.org/simple/ my-super-tool # 2. 确认无误,再传正式 PyPI $ twine check dist/* # 先校验 README 渲染与元数据 $ twine upload dist/* # 3. 用 API token 而不是账号密码(密码方式已在 2024 年停用) # ~/.pypirc [pypi] username = __token__ password = pypi-AgEIcHlwaS5vcmc... # 4. 更现代的姿势:Trusted Publishing(CI 里免 token) # GitHub Actions 上用 OIDC 直接发布,仓库里一个密钥都不用存
# .github/workflows/publish.yml —— 打 tag 自动发布 name: Publish on: push: tags: ["v*"] jobs: build: runs-on: ubuntu-latest permissions: id-token: write # Trusted Publishing 需要这个权限 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 - run: pip install build && python -m build - uses: pypa/gh-action-pypi-publish@release/v1
# 私有 index:公司内网装包 $ pip install --index-url https://nexus.corp/repository/pypi/simple/ my-internal-lib # 或者在依赖里直接引用一个 wheel 地址 # dependencies = ["my-internal-lib @ https://nexus.corp/packages/my_internal_lib-1.2.0-py3-none-any.whl"] # pip.conf 里配默认源 + 额外源,避免每次敲参数 [global] index-url = https://nexus.corp/repository/pypi/simple/ extra-index-url = https://pypi.org/simple/
PyPI 上的版本号是"永久"的,发错了不能覆盖

同一个版本号只能上传一次,传上去就不能删改(只能 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-toolsrequirements.inrequirements.txt(pip-compile 生成)最传统,几乎所有环境都支持
Poetrypyproject.tomlpoetry.lock声明、锁定、构建、虚拟环境一体
PipenvPipfilePipfile.lock热度下降,新项目不推荐
uvpyproject.tomluv.lockRust 实现,解析和安装极快
condaenvironment.ymlconda-lock数据科学场景,含非 Python 依赖
# 方案一:pip-tools(在已有项目上渐进改造最省事) $ pip install pip-tools $ echo "pandas>=2.0" > requirements.in $ pip-compile requirements.in -o requirements.txt # 生成含哈希的锁文件 $ pip-sync requirements.txt # 让环境精确等于锁文件 # requirements.txt 里长这样(含传递依赖和哈希) pandas==2.2.3 \ --hash=sha256:1f3... \ --hash=sha256:9ab... numpy==2.1.3
# 方案二:uv(近年增长最快的选择,速度差距是数量级) $ pip install uv $ uv init my-app # 从零建项目,自动写 pyproject.toml $ uv add pandas click # 加依赖,顺手更新 uv.lock $ uv sync # 按锁文件把环境装到精确状态 $ uv run python -m my_app # 不必先激活 venv 也能跑 $ uv lock --upgrade-package pandas # 只升级 pandas 并重算锁 $ uv pip install requests # uv 也兼容 pip 用法,可渐进迁移 $ uv export --format requirements-txt > requirements.txt # 导出给老流程用

论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,以及 pip freeze 的陷阱

① 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 再发布。