4. pyproject.toml入门
在早期的 Python 项目中,打包配置往往散落在多个文件里:setup.py 负责构建逻辑,setup.cfg 存放元数据,requirements.txt 管理依赖,tox.ini、.flake8、mypy.ini 又各自为政。这种碎片化带来一个直接问题:工具越多,配置文件越多,版本之间还容易互相冲突。PEP 518 和 PEP 621 的相继提出,正是为了解决这一局面,而 pyproject.toml 就是它们共同的落地载体。
PEP 518:先解决“构建系统怎么声明”
PEP 518 的核心贡献是引入 [build-system] 表,让项目能够以声明式的方式告诉前端工具(如 pip):“构建这个项目需要哪些依赖,用哪个构建后端。”在它之前,pip 要构建一个源码包,只能先执行 setup.py,而 setup.py 本身可能依赖尚未安装的库,形成“要构建先安装、要安装先构建”的循环。PEP 518 用一段静态声明打破了这个循环:
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
这三行信息量很大。requires 列出构建该项目所需的包及最低版本,pip 会先在一个隔离环境中安装它们;build-backend 指定真正执行构建的 Python 对象,这里是 setuptools 提供的 build_meta 后端。换成 Hatchling、Flit、PDM 时,只需改这两项,项目源码结构不必跟着变。这就是“构建系统声明”与“项目内容”解耦的价值。
PEP 621:把项目元数据标准化
PEP 518 解决了构建依赖,但项目自身的名称、版本、作者、依赖等信息仍写在 setup.py 或 setup.cfg 里,格式因工具而异。PEP 621 定义了 [project] 表,用统一字段描述项目元数据:
[project]
name = "demo-pkg"
version = "0.1.0"
description = "一个用于演示 pyproject.toml 的示例包"
readme = "README.md"
requires-python = ">=3.9"
license = { text = "MIT" }
authors = [
{ name = "张三", email = "zhangsan@example.com" }
]
dependencies = [
"requests>=2.28",
"click>=8.0",
]
[project.optional-dependencies]
dev = ["pytest>=7.0", "ruff>=0.1"]
这些字段是标准化的,任何遵循 PEP 621 的构建后端都能读取。dependencies 声明运行时依赖,optional-dependencies 声明可选依赖组,安装时用 pip install demo-pkg[dev] 即可带上开发工具。相比过去在 setup.py 里写 install_requires,这种写法更接近数据而非代码,工具解析更稳定,也更容易被 IDE 和依赖分析器理解。
[project] 表的支持,因此同一份元数据可以配合不同构建后端使用。统一入口:三类配置各归其位
把 PEP 518 和 PEP 621 放在一起看,pyproject.toml 的结构就清晰了。它通常包含三类内容:构建系统声明、项目元数据、工具配置。前两类由 PEP 定义,第三类则由各工具自行约定,例如 Ruff、mypy、pytest 都允许把自己的配置写进 [tool.xxx] 表:
[tool.ruff]
line-length = 100
target-version = "py39"
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
[tool.mypy]
python_version = "3.9"
strict = true
这样一来,项目根目录下不再需要 .flake8、mypy.ini、pytest.ini 等一堆文件,工具配置集中在一个 TOML 文件里,阅读和迁移都更方便。需要强调的是,[tool.*] 并非 PEP 621 强制规定,而是各工具主动支持的结果;使用前应确认目标工具版本是否支持从 pyproject.toml 读取配置。
从零写一份可用的 pyproject.toml
假设要创建一个名为 demo-pkg 的包,源码放在 src/demo_pkg/ 下。第一步先写构建系统声明,选择 setuptools 作为后端:
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
第二步写项目元数据。注意 name 是发布名,可以带连字符;实际导入名由包目录决定,这里是 demo_pkg。版本号遵循 PEP 440,0.1.0 表示初始开发版:
[project]
name = "demo-pkg"
version = "0.1.0"
description = "演示 pyproject.toml 的最小项目"
requires-python = ">=3.9"
dependencies = ["requests>=2.28"]
[project.optional-dependencies]
dev = ["pytest>=7.0", "ruff>=0.1"]
第三步按需加入工具配置。例如让 Ruff 使用 100 列宽、目标 Python 3.9,让 pytest 只在 tests/ 下找测试:
[tool.ruff]
line-length = 100
target-version = "py39"
[tool.pytest.ini_options]
testpaths = ["tests"]
写完后可以用 python -m build 验证构建是否成功,产物会出现在 dist/ 目录下,通常是一个 .tar.gz 源码包和一个 .whl 轮子。如果构建报错提示找不到 build-backend,多半是 requires 里的包名或版本写错;如果元数据校验失败,则检查 [project] 中必填字段是否齐全。PEP 621 要求 name 和 version 至少存在其一,若使用动态版本则需在 dynamic 中声明。
[project] 和 [tool.setuptools] 的职责混淆:前者是标准元数据,后者是 setuptools 特有的包发现配置。例如 [tool.setuptools.packages.find] 用于指定从哪个目录查找包,它不属于 PEP 621 范畴。迁移与兼容性判断
如果手头已有 setup.py 项目,迁移时可以分步走:先把 install_requires、name、version 等静态信息搬到 [project],保留 setup.py 中无法声明的动态逻辑;确认构建通过后,再逐步删除冗余的 setup.cfg 和工具配置文件。判断某个工具是否支持 pyproject.toml,最直接的方法是查它的文档中是否出现 [tool.工具名] 配置示例,或运行 工具名 --help 查看是否提及 pyproject。对于仍只认旧配置文件的工具,可以暂时保留原文件,两者并不冲突。
理解 pyproject.toml 的关键,是把它看作一个分层入口:PEP 518 定义构建层,PEP 621 定义元数据层,[tool.*] 定义工具层。三层各司其职,又共享同一个文件,这正是它取代碎片化配置的根本原因。