Python 项目一开始常常只有一个 requirements.txt。等服务变成内部 SDK、命令行工具、后台任务包以后,问题就来了:运行环境装进 pytest 和 black,构建机和线上版本不一致,私有源 token 散落在脚本里,发布出来的 wheel 在另一台机器上装不上。
这篇文章不讲“怎么上传 PyPI”的入门流程,而是按生产发布事故来写:如何用 pyproject.toml 收口元数据和构建后端,如何拆运行依赖、开发依赖和构建依赖,为什么构建隔离能帮你发现隐性依赖,以及上线前怎么检查 wheel、sdist、锁文件和私有仓库凭据。
业务场景:内部 SDK 发布后线上装不上
假设团队维护一个订单 SDK,FastAPI 服务、Celery worker、自动化脚本都会依赖它。最初项目里只有一个 requirements.txt,里面混着运行依赖、测试工具、格式化工具和构建工具。
requests pydantic pytest black setuptools twine uvicorn
本地开发没问题,CI 也能跑。但发布到私有仓库后,线上安装时发现依赖过重,有些机器还因为构建依赖缺失失败。根因很简单:我们没有区分“包运行需要什么”“开发测试需要什么”“构建这个包需要什么”。
第一步:把项目元数据放进 pyproject.toml
现代 Python 包应该让 pyproject.toml 成为入口。它不是某个工具的私货,而是 Python 打包生态的统一配置载体。最小可用结构通常包含 [project] 和 [build-system]。
[project] name = "order-sdk" version = "0.8.3" requires-python = ">=3.11" dependencies = [ "requests>=2.32", "pydantic>=2.7", ] [build-system] requires = ["hatchling"] build-backend = "hatchling.build"
dependencies 只放运行时真正需要的包。pytest、ruff、mypy、twine、build 这类工具不该被线上服务跟着安装。依赖越清楚,供应链扫描、镜像体积、冷启动和故障排查都会简单不少。
第二步:开发依赖用 dependency-groups 分出来
如果你的工具链支持 dependency-groups,可以把测试、格式化、文档依赖拆开。这样 CI 可以按需安装,线上包只声明运行依赖。
[dependency-groups] test = [ "pytest>=8.2", "pytest-cov>=5", ] lint = [ "ruff>=0.5", "mypy>=1.10", ] release = [ "build>=1.2", "twine>=5", ]
我的习惯是:运行依赖写进 [project].dependencies,测试依赖进 test,代码质量工具进 lint,发布工具进 release。不要因为“CI 方便”就把所有东西塞回一个文件。
第三步:用构建隔离暴露隐性依赖
最怕的构建问题,是本地能构建,干净环境不能构建。原因通常是你机器上已经装过某个包,构建脚本偷偷依赖它,但 [build-system].requires 没声明。
python -m pip install --upgrade build python -m build
python -m build 会在隔离环境中构建 sdist 和 wheel。它能逼你把构建依赖写清楚,也能避免宿主环境污染构建结果。CI 里我会从空环境开始构建,而不是复用开发虚拟环境。
第四步:发布前检查 wheel 和 sdist
很多包不是上传失败,而是上传成功以后才发现缺文件、版本号错、元数据错。发布前至少做三件事:
python -m build python -m twine check dist/* python -m pip install --force-reinstall dist/order_sdk-0.8.3-py3-none-any.whl
如果包里有模板、配置样例、迁移文件、类型声明文件,别只看源码目录,要实际安装 wheel 后跑一次最小用例。wheel 能安装,不代表文件齐;文件齐,也不代表导入路径正确。
锁文件不是发布包元数据的替代品
锁文件的价值是让应用部署可复现,而不是让库包把所有下游版本钉死。内部应用可以锁定完整依赖树;内部 SDK 通常只声明合理版本范围,让使用方在自己的应用锁文件里统一解析。
如果团队已经开始使用 pylock.toml 或工具生成的 lock 文件,我建议把它纳入应用仓库 CI:构建镜像、跑测试、生成 SBOM、部署回滚都基于同一份锁定结果。库包则重点保证 dependencies 语义正确,不随手写死过窄版本。
私有源发布:token 不进代码库
发布到私有仓库时,凭据管理比命令本身重要。CI 里使用最小权限 token,只允许上传目标项目;token 存在 CI secret,不写进 pyproject.toml、脚本和镜像层。
python -m twine upload --repository-url "$PRIVATE_PYPI_URL" -u "__token__" -p "$PRIVATE_PYPI_TOKEN" dist/*
发布脚本要打印版本、仓库地址、构建产物摘要,但不要打印 token。出了问题时,日志应该帮你定位版本和产物,不应该帮别人拿到发布权限。
上线检查清单
[project].dependencies只包含运行时依赖,不混入测试、格式化、发布工具。[build-system].requires完整声明构建后端和构建依赖。- 开发依赖按
test、lint、release分组,CI 按需安装。 - 构建必须在干净隔离环境执行,不能依赖本机已安装包。
- 发布前执行
twine check,并从 wheel 安装跑最小导入用例。 - 应用部署使用锁文件保证可复现,库包依赖范围不要无脑钉死。
- 私有源 token 使用 CI secret 和最小权限,不进代码库、不进镜像层。
总结
Python 打包发布的核心不是命令,而是边界。运行依赖、开发依赖、构建依赖、部署锁定、发布凭据,各自解决不同问题。把它们混在一个文件里,短期省事,长期会把 CI、线上安装和供应链治理都变得很脆。
我的经验是,先把 pyproject.toml 写干净,再让 CI 用干净环境构建,最后用 wheel 安装结果验证发布物。这样一个 Python 包从本地开发到私有源,再到线上服务安装,才算真正可控。