"在我机器上能跑"最常见的物理原因是:代码从来没被打成包,或者打成了包但缺文件。pyproject.toml 是 PEP 518/621 定下的单一入口,它同时回答三个问题:用什么构建后端、包里有什么元数据、发到哪个索引。这篇按"声明 → 结构 → 入口 → 构建 → 版本 → 发布 → 排错"的顺序走完一条可发布的链路。
1. pyproject.toml 的三段结构
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build" # 决定谁负责产出 wheel
[project]
name = "demo-service" # 发布名,不是 import 名
version = "0.3.1"
description = "订单服务"
requires-python = ">=3.11"
dependencies = ["httpx>=0.27", "pydantic>=2.6,<3"]
[project.optional-dependencies]
dev = ["pytest>=8", "ruff>=0.4"]
postgres = ["psycopg[binary]>=3.1"]
| 段 | 作用 | 不写会怎样 |
|---|---|---|
[build-system] |
声明构建后端与构建依赖 | 退回遗留 setup.py 路径,行为不可控 |
[project] |
元数据与运行依赖(PEP 621) | 包能构建但无元数据,装不上也发不出 |
[project.optional-dependencies] |
extras,按需安装 | 只能把开发依赖塞进 dependencies,污染使用者 |
后端选择上,hatchling 与 setuptools 都可,flit-core 适合纯 Python 单模块。选定的后端决定"哪些文件会被收进 wheel",这是后面所有"包不完整"问题的根源。
2. 项目结构:src 布局 vs 平铺布局
# src 布局(推荐)
demo-service/
├── pyproject.toml
├── src/demo_service/__init__.py
└── tests/test_api.py
# 平铺布局
demo-service/
├── pyproject.toml
├── demo_service/__init__.py
└── tests/test_api.py
| 维度 | src 布局 | 平铺布局 |
|---|---|---|
| 导入隔离 | 未安装时 import 会失败,逼你先装 | 当前目录直接可 import,掩盖打包错误 |
| 测试可信度 | 测的是装好的包 | 测的是源码目录,可能漏文件 |
| 打包配置 | 需在 [tool.hatch.build.targets.wheel] 写 packages = ["src/demo_service"] |
自动发现 |
| 适用 | 需要发布、有 CI 的项目 | 一次性脚本、内部小工具 |
src 布局的核心收益是让"没装好"立刻暴露。平铺布局下 import demo_service 永远成功,直到用户在别的机器上装了这个包才发现少了子模块。
3. [project.scripts] 生成命令行入口
[project.scripts]
demo-cli = "demo_service.cli:main"
[project.gui-scripts]
demo-gui = "demo_service.gui:main"
# src/demo_service/cli.py
def main() -> int:
print("hello")
return 0
装包后 demo-cli 就能直接执行。它的实现是在 bin/(Windows 是 Scripts/)里生成一个极薄的启动脚本,import 目标模块并调用函数。
| 项目 | 规则 | 常见错 |
|---|---|---|
| 左值 | 命令名,建议与包名同前缀 | 与系统已有命令重名 |
| 右值 | 模块路径:函数名 |
写成 文件名.py:main |
| 函数签名 | 无参或只接 argv,返回 int 或 None |
忘了 sys.exit(main()) 导致退出码恒为 0 |
| 位置 | 必须在 [project] 下 |
写进 [build-system] 里,静默失效 |
4. 构建:python -m build 的两个产物
pip install build
python -m build # 同时产出 sdist 与 wheel,输出到 dist/
python -m build --wheel # 只出 wheel
python -m build --sdist # 只出 sdist
unzip -l dist/demo_service-0.3.1-py3-none-any.whl
| 产物 | 内容 | 用途 |
|---|---|---|
*.whl(wheel) |
已构建的可安装归档,含 .dist-info |
极速安装,无需构建环境 |
*.tar.gz(sdist) |
源码 + 构建脚本 | 供他人从源码构建;跨平台分发的兜底 |
*.dist-info/METADATA |
名称、版本、依赖 | 安装器据此解析依赖 |
*.dist-info/RECORD |
文件清单与哈希 | 卸载与完整性校验依据 |
wheel 的文件名编码了三条信息:{name}-{version}-{pyver}-{abi}-{platform}.whl。纯 Python 包是 py3-none-any;一旦带编译扩展,platform 就必须区分 manylinux/macosx,这也是"为什么我的包在别人机器上装不上"的常见原因。
5. 版本号:单一来源
# src/demo_service/__init__.py
__version__ = "0.3.1"
[project]
dynamic = ["version"]
[tool.hatch.version]
path = "src/demo_service/__init__.py"
| 策略 | 优点 | 缺点 |
|---|---|---|
只写在 pyproject.toml |
唯一来源,构建即真相 | 运行时取版本要 importlib.metadata.version("demo-service") |
只写在 __init__.py + dynamic |
代码里可直接读 __version__ |
依赖后端支持动态版本 |
| 两处各写一份 | 看起来最省事 | 必然不同步,且不会有任何报错 |
setuptools-scm |
从 git tag 自动生成 | 需要打 tag,sdist 外无 git 信息 |
语义化版本(MAJOR.MINOR.PATCH)的边界要明确:破坏兼容改 MAJOR、加功能改 MINOR、修 bug 改 PATCH。对于 0.y.z 阶段,很多团队约定 MINOR 也可以破坏兼容——这个约定必须写进 README,否则下游会把 0.3 → 0.4 当成安全升级。
6. 发布到私有源:twine
pip install twine
twine check dist/* # 先校验元数据与 README 渲染
twine upload --repository-url https://pypi.example.com/simple/ \
--username "$USER" --password "$TOKEN" dist/*
# ~/.pypirc(权限 600)
[distutils]
index-servers = internal
[internal]
repository = https://pypi.example.com/simple/
username = ci-bot
password = __token__
| 步骤 | 命令 | 检查点 |
|---|---|---|
| 元数据校验 | twine check dist/* |
README 的 content-type 是否识别 |
| 上传 | twine upload ... |
版本号是否已被占用,占用会 400 |
| 安装验证 | pip install --index-url ... demo-service==0.3.1 |
真能从私服装到 |
| 内容核对 | pip download 后看 wheel 里的文件 |
子包与数据文件是否齐全 |
twine 之前用 python setup.py upload,后者已被弃用且不支持现代后端。凭证不要写在命令行历史里——用 __token__ + 环境变量或 keyring 注入,比 --password 明文更稳。
7. 可编辑安装与本地调试
pip install -e . # 从源码目录导入,改代码即时生效
pip install -e ".[dev]" # 连开发依赖一起装
python -c "import demo_service, sys; print(demo_service.__file__)"
| 方式 | 生效范围 | 何时用 |
|---|---|---|
pip install -e . |
当前虚拟环境 | 日常开发 |
pip install . |
复制进 site-packages | 验证"打出来的包能用" |
uv run --with . |
临时环境 | 试装不改动现有环境 |
本地调试有个反直觉的点:editable 安装不会验证打包是否完整。它把 import 重定向到源码目录,缺 __init__.py、漏数据文件在 editable 模式下全都看不出来。发布前必须做一次 pip install dist/*.whl 到干净环境并跑一遍入口命令,这是唯一能验证 wheel 内容的方式。
8. 依赖与 extras 的边界
| 类别 | 放哪 | 例子 |
|---|---|---|
| 运行必需 | dependencies |
httpx、pydantic |
| 可选功能 | [project.optional-dependencies] |
postgres、redis |
| 开发工具 | dev extra |
pytest、ruff、mypy |
| 仅测试用 | 同 dev,或单独的 test extra |
pytest-asyncio |
| 构建期依赖 | [build-system].requires |
hatchling |
两条边界原则:运行依赖里不要出现测试工具(用户装你的包不该顺带装 pytest);extras 要能独立安装(pip install demo-service[postgres] 不装 dev 也成立)。把 psycopg 直接写进 dependencies 会让不用数据库的用户也背上一个二进制依赖。
9. 排错:四类打包坑
| 现象 | 根因 | 处理 |
|---|---|---|
InvalidDistribution: Invalid distribution name |
包名含大写、空格或 Unicode | 只用小写字母、数字、-/_,且以字母数字开头结尾 |
装上了但 import 报 ModuleNotFoundError |
包没被收进 wheel(后端没配置 packages) | 检查 [tool.hatch.build.targets.wheel] packages 或 setuptools 的 packages.find |
| 子包缺失 | 目录没有 __init__.py(非 namespace 包时) |
补齐 __init__.py,或显式声明命名空间包 |
| 版本号两处不一致 | pyproject.toml 与 __init__.py 各写一份 |
用 dynamic = ["version"] 单一来源 |
twine upload 报 400 已存在 |
同版本号重复上传 | 提版本号,不要覆盖 |
| 数据文件(模板/证书)没进包 | 未被包含在 package data 里 | 显式声明 include,或改用 importlib.resources 读取 |
第一条的规则细节:PyPI 上包名规范化会把 -/_/. 视为等价并转小写,但上传时仍要求合法字符集。本地目录名可以随便起,[project].name 不行。
10. 边界
| 场景 | 更合适的选择 | 理由 |
|---|---|---|
| 只是内部脚本,不发布 | 不打包,直接跑 | 打包成本高于收益 |
| 需要单文件分发 | pex / shiv / PyInstaller |
wheel 仍需 pip 环境 |
| 依赖含 C 扩展、要跨平台 | cibuildwheel + manylinux 镜像 | 需要为每个平台出 wheel |
| 应用部署 | 容器镜像 | 镜像自带解释器与依赖 |
| 单仓多包 | workspace(uv/hatch 的 monorepo 支持) | 各自独立发版 |
| 需要编译期定制 | setuptools + 自定义 build_ext |
现代后端不一定给足钩子 |
11. 自测
[build-system]、[project]、[project.optional-dependencies]三段各自回答什么问题?- src 布局相比平铺布局,多拦住了一类什么错误?
[project.scripts]的右值写成模块:函数,装包后bin/里多了什么?- wheel 与 sdist 的差别是什么?什么时候必须依赖 sdist?
- 版本号同时写在
pyproject.toml和__init__.py里,为什么这是必然出错的配置?单一来源怎么配? - editable 安装能验证 wheel 内容完整吗?发布前应该补哪一步验证?