本文目录
仓库里代码能 python main.py 跑通,和「别人 pip install yourpkg 就能 import yourpkg」之间,差的是一套构建与发布约定。现代 Python 把项目身份、依赖、构建方式写进 pyproject.toml,构建工具产出 wheel(或 sdist),再推到 PyPI 或私有索引。下面走一条最小路径,不展开历史 setuptools 脚本的每个参数。
系列前面讲过依赖与虚拟环境;这一篇回答「自己的库怎样变成别人能装的分发包」。从写脚本到发 wheel,中间多的是元数据与构建约定,不是多写几百行业务代码。把最小路径走通一次,以后加字段、加可选依赖、接 CI 都有落脚点。
pyproject.toml:项目身份证
PEP 518 起,pyproject.toml 是构建与元数据的入口。最少要声明 build-system(谁负责 build)和 project(包名、版本、依赖):
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "tinygreet"
version = "0.1.0"
description = "A minimal greeting library"
readme = "README.md"
requires-python = ">=3.11"
dependencies = []
[project.optional-dependencies]
dev = ["pytest>=8.0"]
[tool.hatch.build.targets.wheel]
packages = ["src/tinygreet"]hatchling、setuptools、flit 等是常见 build-backend;选一种并写进 requires 即可。团队一旦选定后端,成员与 CI 都用同一套 python -m build,避免「我本地能装、你那边缺脚本」的漂移。
源码布局常用 src/ 包目录:测试代码在 tests/,不会被误打进发行包;可编辑安装时 import 的仍是安装后的包路径,更接近用户真实环境。
目录与可 import 的包
最小可安装结构示意:
tinygreet/
pyproject.toml
README.md
src/
tinygreet/
__init__.py
core.pycore.py 里放实际逻辑;__init__.py 决定对外暴露什么:
"""tinygreet — minimal package for build illustration."""
from __future__ import annotations
def greet(name: str) -> str:
return f"hello, {name}"
__all__ = ["greet"]本地开发时可用可编辑安装,改代码不必反复 build:
python -m pip install -e ".[dev]"
python -c "from tinygreet import greet; print(greet('world'))"-e 表示 editable:解释器通过链接或映射找到源码树,你改 core.py 立刻生效。发版前仍应打正式 wheel 验证「非 editable 安装」是否正常。
build:从源码到 wheel
安装构建前端 build,在项目根执行:
python -m pip install build
python -m build会在 dist/ 生成:
| 产物 | 含义 |
|---|---|
*.whl | wheel,预构建格式,pip install 优先选它 |
*.tar.gz | sdist 源码包,无 wheel 的平台会用它再 build |
wheel 文件名里带平台标签:py3-none-any.whl 表示纯 Python、任意平台。含 C 扩展的包会出现 cp311-manylinux 等标签,说明这条 wheel 绑定了 Python 小版本与操作系统 ABI。
构建前可用 importlib.metadata 在已安装环境里核对元数据是否与 pyproject.toml 一致:
from importlib.metadata import metadata, version
pkg = "tinygreet"
print(version(pkg))
print(metadata(pkg)["Name"], metadata(pkg)["Version"])发版流水线里常在这一步跑测试:先 pip install dist/*.whl 到干净虚拟环境,再 pytest,确认打进包里的文件完整。
发布:上传到索引
发布到 PyPI 的典型步骤:
- 在 pypi.org 注册账号,配置 API token(不要用账号密码上传)。
- 安装 twine:
python -m pip install twine。 - 上传:
python -m twine upload dist/*。
首次发布前用 TestPyPI 试跑一遍更稳:
python -m twine upload --repository testpypi dist/*
python -m pip install --index-url https://test.pypi.org/simple/ tinygreet生产发布时注意版本号:PyPI 上同一 name + version 不可覆盖,只能发新版本。打错包只能 yank(标记不推荐),不能悄悄替换文件内容。
版本与标签习惯
版本写在 pyproject.toml 的 project.version,或由 hatch-vcs 等插件从 git tag 生成。团队常约定 tag v0.1.0 与 version = "0.1.0" 对齐,CI 里 python -m build 后 twine upload 自动化。变更日志与版本号同步,使用者 pip install 时才知道升级了什么。
私有索引与内网发布
企业常把 wheel 推到内网制品库,而不是公网 PyPI。pip 通过 --index-url 或 pip.conf 指向私有源;twine upload 同样可配置 repository 段。物理规则不变:build 产出 dist/,upload 推到索引,使用者 pip install 时从索引拉 wheel。内网发布仍要钉版本、写变更说明,避免「最新」标签掩盖 breaking change。
与 requirements 导出
pyproject.toml 是依赖的源时,可用 pip compile 或 uv export 生成 requirements.txt 给旧流水线用。打包发布关心的是「库本身」的分发包;应用项目关心的是「装哪些第三方」。同一个 pyproject 可以同时声明 project.dependencies(运行时)与 optional-dependencies dev(测试工具),build 时只打进库代码,不会把 pytest 塞进使用者环境,除非他们显式装 dev 组。
打 sdist 时,MANIFEST.in 或 build 后端配置决定哪些非 Python 文件(README、类型 stub、数据文件)进包。漏了 README 不影响 import,但 PyPI 页面会缺描述;漏了 py.typed 则类型检查器不知道包带类型信息。发版前在干净 venv 里 pip install 刚打的 wheel,跑一遍最小 import 与 doctest,比 upload 后才发现缺文件省事。
setuptools 时代常见的 setup.py 动态读版本,现在更推荐静态写在 pyproject 或由 VCS 插件生成,减少 import 时执行 setup 的副作用。无论后端选谁,dist/ 里的产物应是可重复 build 的:同一 tag checkout 出来,CI 与本地 build 应得到相同版本号与包内容哈希(纯 Python 包通常一致)。
常见误解
| 误解 | 实际 |
|---|---|
| 有 setup.py 才能打包 | pyproject.toml + build-backend 即可 |
| wheel 与 sdist 是一回事 | wheel 预构建;sdist 需再编译 |
| 发 PyPI 后还能改同版本文件 | 不可覆盖,只能发新版本或 yank |
| 测试目录会自动打进包 | 要在 build 配置里声明 packages |
library 与 application 的打包目标不同:库要 stable 的 import 路径与 semver;应用可能只打 Docker 镜像而不上 PyPI。即使不上传公网,本地 wheel 也能让其他项目 pip install 你的内部包,比 PYTHONPATH 指向源码树更干净。团队内网 devpi 或 Artifactory 扮演 PyPI 角色,命令仍是 build 与 twine upload,只是 URL 不同。
第一次发版前 checklist:pyproject 里 name 与 import 名一致、version 已 bump、README 在 manifest 里、tests 在 CI 用 wheel 安装后通过、TestPyPI 能 pip install。正式 upload 前删除 dist/ 里旧产物,避免误传上一版的 wheel。发布是工程动作,与写功能代码一样值得 checklist。
维护已发布包时,破坏性变更应升 major 版本,新功能升 minor,补丁升 patch——semver 约定写在 CONTRIBUTING 里,review 时对照 pyproject 的 version 字段。使用者 pin 你的包时,清晰的版本策略比频繁 breaking release 更可持续。内部库若不上 PyPI,也建议在 git tag 与 pyproject version 之间保持一致,方便运维对照部署物。
classifiers 与 project.urls 等字段写在 pyproject 里,会出现在 PyPI 页面,便于使用者判断许可证与文档链接。requires-python 声明过低会吸引不兼容用户;声明过高则缩小受众。build 前用 python -m build 在干净容器里试装,能提前发现漏文件或错误包名,比 upload 后才发现 import 失败成本低得多。
可选依赖组 dev、docs、test 写在 optional-dependencies,使用者 pip install pkg[dev] 才装开发工具,保持运行时镜像 slim。CI 里 build wheel 与跑测试应用同一 pyproject 声明,避免「本地 editable 能跑、wheel 缺模块」的 split-brain。发布流水线 artifact 保留 dist/ 若干天,便于回滚对照 checksum。
从应用仓库切到可发布库时,最先改的是包名与 import 路径稳定性:公开 API 放在 init.py 的 all 里,内部模块以下划线或包内路径隐藏。build 成功后用 pip install 在空 venv 里验证 import 路径与文档示例一致,这条比多记 ten twine 参数更能减少首发事故。
LICENSE 文件与 pyproject 里 license 字段应一致;README 里写安装示例 pip install yourpkg,与 PyPI 页面 name 相同。私有包同样走 build 产出 wheel,只是 upload 目标换成内网 URL;命令序列不变,变的是配置与权限。
long_description 通常来自 README;若 README 用 Markdown,build 后端会打包进 metadata。发版前在本地 pip install wheel 后 python -c "import pkg; help(pkg)" 看一眼 docstring 与版本是否如预期。构建失败时先读 pyproject 的 build-system 与 packages 配置,比反复 twine upload 更能定位缺模块问题。
把 build 与 upload 拆成 CI 两个 job:PR 只 build 与试装,tag 后才 upload,能减少误发版。dist/ 加入 gitignore,产物只走 artifact 或制品库,仓库里不堆积 wheel 二进制。
初学者常问 setuptools 与 hatchling 选哪个:能跑通一条链即可,团队统一比个人偏好重要。迁移旧 setup.py 项目时,可先用 setuptools 作 build-backend,再逐步收拢到 pyproject-only;关键是 wheel 能装、import 路径对、版本可追踪。
发布不是终点:使用者 issue 里问安装失败,先让他们 pip show 与 python -c "import sys; print(sys.executable)" 对照,再查 wheel 平台标签是否匹配。维护者回滚时用 yank 标记问题版本,发 patch 版修复,比强行覆盖 PyPI 文件符合生态规则。
收束
打包的核心是把「可运行的源码树」变成标准分发格式:pyproject.toml 声明怎么 build,wheel 让安装变快变稳,发布是把 dist/ 推到索引供他人 pip install。先打通 build → 本地 pip install dist/*.whl → TestPyPI,再考虑正式发布,比一上来记全 setuptools 指令更不容易迷路。