2329 字
约 7 分钟
15
S10-X04 · 打包与发布:pyproject 与 wheel

"在我机器上能跑"最常见的物理原因是:代码从来没被打成包,或者打成了包但缺文件。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. 自测

  1. [build-system]、[project]、[project.optional-dependencies] 三段各自回答什么问题?
  2. src 布局相比平铺布局,多拦住了一类什么错误?
  3. [project.scripts] 的右值写成 模块:函数,装包后 bin/ 里多了什么?
  4. wheel 与 sdist 的差别是什么?什么时候必须依赖 sdist?
  5. 版本号同时写在 pyproject.toml 和 __init__.py 里,为什么这是必然出错的配置?单一来源怎么配?
  6. editable 安装能验证 wheel 内容完整吗?发布前应该补哪一步验证?
S10-X04 · 打包与发布:pyproject 与 wheel
https://www.clxhxhhr.top/posts/5746/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。