《Python编程入门》15.1 pyproject.toml 与依赖管理

从 setup.py 与 requirements.txt 的历史包袱讲起,用 PEP 518/517/621 三条线串起 pyproject.toml 的来历;拆解 build-system 后端选型与 project 表标准字段,重点实测依赖说明符中 ~= 的锁定边界与逗号、空格的区别,并给出经 tomllib 校验的完整配置。

本节目标:看懂 pyproject.toml 每个字段从哪条 PEP 来、该写什么,并能亲手写出一份通过解析校验的项目配置。
适用版本:Python 3.12+(实测 3.14.6)

15.1 pyproject.toml 与依赖管理

2.3 节我们第一次见到 pyproject.toml,当时只是把它当作「新项目该有的那个文件」。这一节要把它彻底讲透:它为什么会出现、每个字段的来历、以及如何用版本说明符精确表达「我要哪个版本的依赖」。这些知识是下一节构建 wheel、再下一节锁定版本的地基。

15.1.1 从 setup.py 与 requirements.txt 说起

在 pyproject.toml 出现之前,一个 Python 项目的元数据分成两处,且都是「运行时执行代码」:

# 老项目典型布局
mypkg/
├── setup.py          # 可执行的构建脚本
├── requirements.txt  # 运行依赖(格式松散)
└── mypkg/
    └── __init__.py

setup.py 里调用 setup(...),构建工具必须执行这个脚本才能知道包名、版本、依赖。这意味着安装一个包就等于运行它作者的任意代码——供应链安全上是个大洞,而且不同工具的解析规则还互不兼容。requirements.txt 更松散,它没有标准,只是「一行一个 pip install 参数」。

于是社区做了三件事,正好对应接下来要讲的三个 PEP。

15.1.2 三个 PEP 各解决什么

PEP年份解决的问题引入的东西
PEP 5182016构建项目需要哪些工具、用什么后端[build-system] 表、pyproject.toml 文件本身
PEP 5172017后端与前端解耦,定义统一构建接口build-backend、build_wheel / build_sdist 钩子
PEP 6212020项目元数据写成静态声明,不再执行代码[project] 表

一句话记忆:518 建了房子(文件),517 定了接口(后端协议),621 装进家具(元数据字段)。三者叠加后,pip 不再需要执行 setup.py 就能读出依赖,安装过程变成纯数据解析。

15.1.3 [build-system]:构建后端怎么选

[build-system] 只有两个关键键:requires 列出构建时需要的工具,build-backend 指向真正干活的模块。

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

常见后端对比如下。纯 Python 项目四者都能用,差别在配置风格与功能:

后端代表工具配置位置特点
setuptools.build_metasetuptools[tool.setuptools]生态最广、兼容老项目;配置略啰嗦
hatchling.buildHatch[tool.hatch]现代默认,功能全,支持版本插件
flit_core.buildapiFlit[tool.flit]极简,适合「一个模块」的小库,强制 src 布局
pdm-backendPDM[tool.pdm]与 PDM 工具链绑定,支持动态版本

选型建议很朴素:新库用 hatchling,要兼容存量生态就用 setuptools。本节后面统一用 setuptools,因为它在 CI 与老环境里最不容易出意外。

15.1.4 [project]:PEP 621 标准字段

[project] 是元数据的正主,键名由 PEP 621 统一定义,所有后端都必须认识:

字段作用备注
name分发名(PyPI 上的名字)必填,用连字符,不是 import 名
version版本号与 dynamic 二选一
description一句话简介显示在 PyPI 标题下
readme长描述文件通常 "README.md",会渲染成项目主页正文
requires-python支持的 Python 范围如 ">=3.12"
license许可证现代写法是 SPDX 字符串,如 "MIT"
authors / maintainers作者列表表数组 [{ name = ..., email = ... }]
dependencies运行依赖数组安装时自动装
optional-dependencies可选依赖分组即 extras
classifiersPyPI 分类标签供检索,不参与解析
urls项目相关链接主页、仓库、文档等

15.1.5 依赖与版本说明符

dependencies 里的每一项都是一个 PEP 508 依赖说明符。版本比较部分遵循 PEP 440,最常用的几个:

说明符含义例子
==精确等于(可配 .*)==1.4.2、==1.4.*
!=排除某版本!=1.4.2
>= <= > <区间>=1.4.2,<2.0
~=兼容发布(compatible release)~=1.4.2
===任意字符串精确匹配几乎不用

~= 最容易被误解:它到底锁几位? 答案取决于你写了几个版本段。用 packaging 实测(版本 26.3):

from packaging.specifiers import SpecifierSet

ss = SpecifierSet("~=1.4.2")
for v in ["1.4.1", "1.4.2", "1.4.9", "1.5.0", "2.0.0"]:
    print(v, ss.contains(v))
1.4.1 False
1.4.2 True
1.4.9 True
1.5.0 False
2.0.0 False

结论清晰:~=1.4.2 等价于 >=1.4.2, <1.5.0——锁到次版本。再看两个对照:

from packaging.specifiers import SpecifierSet
print("~=1.4   接受 1.5.0 吗:", SpecifierSet("~=1.4").contains("1.5.0"))
print("~=1.4.0 接受 1.5.0 吗:", SpecifierSet("~=1.4.0").contains("1.5.0"))
~=1.4   接受 1.5.0 吗: True
~=1.4.0 接受 1.5.0 吗: False

规律是:~= 锁定最后一个版本段之前的部分。~=1.4 允许到 1.x(<2.0),~=1.4.0 只允许到 1.4.x(<1.5.0)。写多一位,锁定就更紧一格。

逗号是必需的,空格分隔会被拒绝。这一点常被写错,用 pip 实测(版本 26.2.1):

python -m pip install --dry-run "packaging>=1.0 <2.0"
ERROR: Invalid requirement: 'packaging>=1.0 <2.0': Expected comma (within version specifier), semicolon (after version specifier) or end

换成逗号 "packaging>=1.0,<2.0" 就能正常解析。旧工具偶尔容忍空格,但现代 pip 与 packaging 都只认逗号,别踩这个坑。

15.1.6 可选依赖与依赖组

开发时才需要的工具(pytest、ruff、mypy)不该塞进 dependencies,否则用户装你的库会平白多装一堆。用 [project.optional-dependencies] 分组:

[project.optional-dependencies]
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.11"]

用户执行 pip install "mypkg[dev]" 才会装上 dev 组。extras 是「面向用户的可选功能」,比如一个库的 pdf 组装 PDF 解析依赖。

PEP 735 又引入了一个新表 [dependency-groups],专门放**「只给开发者自己用」的依赖**,语义比 extras 更纯粹:

[dependency-groups]
test = ["pytest>=8", "pytest-cov>=6"]

区别一句话:extras 是给使用者的,dependency-groups 是给贡献者的。前者发布到 PyPI 元数据里,后者不会。

15.1.7 requires-python、classifiers 与 dynamic

requires-python 是解析期的硬约束:写 ">=3.12",pip 在 3.11 上就会直接拒绝安装,不会「装了再报错」。classifiers 则是给人看的标签,两者要口径一致:

requires-python = ">=3.12"
classifiers = [
  "Programming Language :: Python :: 3",
  "Programming Language :: Python :: 3.12",
]

如果版本号由 Git 标签或构建时环境推导,就用 dynamic 声明,告诉工具「这个字段别在静态表里找」:

[project]
name = "mypkg"
dynamic = ["version"]

用 dynamic 时,version 不能再写死,必须由后端(如 setuptools 的 [tool.setuptools.dynamic])或版本插件提供。

15.1.8 一份完整可用的 pyproject.toml

把上面的字段拼起来,得到一份能直接用的配置。写完用标准库 tomllib(3.11 起内置,见 10.3 节)解析一遍,确认语法无误:

[build-system]
requires = ["hatchling>=1.25"]
build-backend = "hatchling.build"

[project]
name = "pyintro-demo"
version = "0.4.1"
description = "演示 pyproject.toml 标准字段的示例项目"
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
authors = [{ name = "Leeting Yan", email = "leeting@example.com" }]
keywords = ["tutorial", "packaging"]
classifiers = [
  "Programming Language :: Python :: 3",
  "Programming Language :: Python :: 3.12",
]
dependencies = [
  "httpx>=0.27,<1",
  "pydantic~=2.13",
]

[project.optional-dependencies]
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.11"]

[dependency-groups]
test = ["pytest>=8", "pytest-cov>=6"]

[project.urls]
Homepage = "https://example.com/pyintro-demo"
Repository = "https://github.com/example/pyintro-demo"

[project.scripts]
pyintro-demo = "pyintro_demo.cli:main"

校验脚本与真实输出:

import tomllib, pathlib

data = tomllib.loads(pathlib.Path("pyproject.toml").read_text(encoding="utf-8"))
print("顶层表:", list(data))
print("project 字段:", list(data["project"]))
print("dependencies =", data["project"]["dependencies"])
print("dependency-groups keys =", list(data["dependency-groups"]))
顶层表: ['build-system', 'project', 'dependency-groups']
project 字段: ['name', 'version', 'description', 'readme', 'requires-python', 'license', 'authors', 'keywords', 'classifiers', 'dependencies', 'optional-dependencies', 'urls', 'scripts']
dependencies = ['httpx>=0.27,<1', 'pydantic~=2.13']
dependency-groups keys = ['test']

tomllib 只保证 TOML 语法正确,不校验字段语义;真正的字段检查要等构建时后端来做(下一节 python -m build 会暴露字段错误)。但先用 tomllib 过一遍能挡掉绝大多数手写配置的语法事故。

小结

  • 元数据从「可执行的 setup.py」演进为「静态的 pyproject.toml」,PEP 518 建文件、517 定后端接口、621 定字段。
  • [build-system] 声明构建后端;纯 Python 项目里 hatchling 与 setuptools 最常用。
  • [project] 是 PEP 621 标准字段区;dependencies 写运行依赖,optional-dependencies(extras)给用户,[dependency-groups](PEP 735)给贡献者。
  • 版本说明符里 ~=1.4.2 等价于 >=1.4.2,<1.5.0;多个约束必须用逗号连接,空格会被拒绝。
  • 用 tomllib 解析一遍是零成本的自检手段,能挡住手写 TOML 的语法错误。

配置写对了只是第一步,下一节我们把这份 pyproject.toml 真正「打」成 sdist 与 wheel,装上入口点命令,再走通发布 PyPI 的流程。

阅读导航:上一节:14.3 覆盖率、ruff/mypy 与 CI 门禁 · 下一节:15.2 构建 wheel、入口点与发布 PyPI 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时