《Python编程实战》1.2 pyproject.toml 全解与类型检查分层

pyproject.toml 是现代 Python 项目唯一的配置中心。本节逐段拆解它的四个区段——build-system、project、dependency-groups、tool.*,再用实测代码演示 mypy 的三档分层:核心模块严格、适配层宽松、遗留模块豁免,让类型检查既能守住关键逻辑,又不被历史包袱拖垮。

本节目标:读懂 pyproject.toml 的每一个区段,掌握 mypy 分层配置的写法;读完后你能为一个真实项目设计「核心严格、适配宽松、遗留豁免」的类型检查策略。
适用版本:Python 3.12+(实测 3.14.6);mypy 2.4.0

1.2 pyproject.toml 全解与类型检查分层

上一节用 uv init 生成了 pyproject.toml,但只填了最少的字段。这个文件是整个项目的配置中心:依赖、构建、代码风格、类型检查、测试参数全都写在这里。本节先把它的结构彻底拆开,再解决一个更棘手的问题——类型检查到底该多严。

一份文件,四个区段

pyproject.toml 的内容可以按「谁在读它」分成四段:

区段谁在读管什么
[build-system]构建后端怎么把项目打成 wheel
[project]包管理器、PyPI项目元数据与运行时依赖
[dependency-groups]uv / pip开发期依赖分组(PEP 735)
[tool.*]各工具自己ruff、mypy、pytest 的配置

它取代了 setup.py + setup.cfg + .flake8 + mypy.ini + pytest.ini 这一堆分散文件。好处很实在:换项目时不用满仓库找配置,一个文件看全。

[project]:项目的身份证

这是元数据核心,字段含义基本可以从名字读出:

[project]
name = "demo"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "idna==3.20",
]

[project.scripts]
demo = "demo:main"

几个值得单独说的字段:

  • requires-python = ">=3.12" 是安装门槛。别人用 3.11 装这个包,包管理器会直接拒绝,而不是装完运行到一半才崩。本书基线就是它。
  • dependencies 里每一项都要固定版本(如 "idna==3.20"),这是可复现构建的起点。
  • [project.scripts] 把「模块里的函数」注册成命令行命令,格式是 命令名 = "包.模块:函数"。

version 也可以交给工具动态推断,但在工程实践中,显式写死版本号更利于审计与回滚。

[build-system]:谁负责打包

[build-system]
requires = ["uv_build>=0.12.23,<0.13.0"]
build-backend = "uv_build"

requires 是构建时需要的工具,build-backend 指定用哪个后端。常见选择有 hatchling、setuptools、uv_build。这段只在你构建 wheel(uv build)或安装成本地包(uv sync / uv run)时起作用,日常写代码感知不到它。

一个易踩的坑:requires 里的约束要留出小版本升级空间,但别开太大口子。uv_build>=0.12.23,<0.13.0 表示「0.12 线内随便升,不跨到 0.13」,避免后端行为突变把构建搞挂。

[dependency-groups]:开发依赖不该进运行时

上一节用 uv add --dev 生成的正是这一段:

[dependency-groups]
dev = [
    "mypy==2.4.0",
    "ruff==0.16.10",
]

PEP 735 把「依赖组」标准化了,比过去塞进 [project.optional-dependencies] 更贴合「开发期工具」这个语义。实际收益在部署时体现:uv sync 默认装 dev 组,而生产环境 uv sync --no-dev 只装运行时依赖,镜像更小、攻击面更窄。

[tool.*]:把工具配置收进来

这一段没有统一规范,「谁的工具谁配置」。一个典型项目会同时放三样:

[tool.ruff]
line-length = 100
target-version = "py312"

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM", "C4"]

[tool.mypy]
python_version = "3.12"
strict = true
show_error_codes = true

[tool.pytest.ini_options]
testpaths = ["tests"]

把 line-length、规则集、类型检查严格度、测试路径全部集中在一处,团队新人第一天就能看明白「这个项目的代码标准是什么」。ruff 的规则集怎么选,下一节 1.3 代码规范、pre-commit 与提交门禁 会展开。

类型检查为什么要分层

现在进入本节的重头戏。假设你接手一个中等规模的项目,里面有三类代码:

  • 核心业务逻辑:状态机、金额计算、权限判断——错一个类型就是线上事故,必须严格。
  • 外部适配层:HTTP 客户端、数据库驱动封装——大量第三方库没有类型标注,强求严格会淹没在 Any 里。
  • 遗留模块:早年写的、没人敢动的老代码——现在给它加类型标注成本极高、收益极低。

如果全局开 strict = true,第二、三类会报出成百上千条噪音,团队很快就学会「看见红字就忽略」——类型检查形同虚设。如果全局关掉,核心逻辑又失去保护。正确做法是分层:不同目录用不同的严格度。

实测:mypy 三档分层

我们搭一个最小项目来验证。目录结构:

layer/
├── pyproject.toml
└── src/
    └── app/
        ├── core/service.py      # 核心:严格
        ├── adapters/http.py     # 适配:宽松
        └── legacy/old.py        # 遗留:豁免

配置写进 pyproject.toml,用 [[tool.mypy.overrides]] 逐层覆盖:

[tool.mypy]
python_version = "3.14"
strict = true
files = ["src"]
show_error_codes = true

[[tool.mypy.overrides]]
module = "app.adapters.*"
disallow_untyped_defs = false

[[tool.mypy.overrides]]
module = "app.legacy.*"
ignore_errors = true

三段配置对应三档:

目录档位效果
app.core.*严格(继承全局 strict)缺类型标注、类型不匹配全报
app.adapters.*宽松(关掉 disallow_untyped_defs)允许无标注函数,但仍检查类型错误
app.legacy.*豁免(ignore_errors)整个模块跳过检查

三个文件的内容分别是:

# core/service.py
def total(items: list[int]) -> int:
    return sum(items)


def loose(x):
    return x
# adapters/http.py
def fetch(url):
    return {"url": url, "status": 200}


def parse(payload: dict[str, int]) -> int:
    return payload["status"] + "x"
# legacy/old.py
def legacy_func(a, b):
    return a + b

运行 mypy(配置里的 files = ["src"] 让它无需参数即可扫描):

mypy
src/app/core/service.py:5: error: Function is missing a type annotation  [no-untyped-def]
src/app/adapters/http.py:6: error: Unsupported operand types for + ("int" and "str")  [operator]
Found 2 errors in 2 files (checked 7 source files)

结果正是分层想要的效果:

  • core 报了 no-untyped-def——严格档不放过任何无标注函数。
  • adapters 的 fetch 没标注却没报错(宽松档放行),但 parse 里 int + str 的真实类型错误依然被抓到。这说明「宽松」不等于「放弃检查」,只是不再强制每个函数都写标注。
  • legacy 完全安静——豁免档按预期跳过了 legacy_func。

关键认知:disallow_untyped_defs = false 只是允许无标注,不是关掉检查;真正彻底跳过要用 ignore_errors = true。这两者语义不同,别混用。

pyright 的等价配置

pyright 是另一个主流类型检查器,本机未安装,下面这段未实测,仅作对照示意。它的分层思路一样,只是语法不同(写在 pyrightconfig.json 或 [tool.pyright] 里):

{
  "include": ["src"],
  "strict": ["src/app/core"],
  "basic": ["src/app/adapters"],
  "exclude": ["src/app/legacy"]
}

pyright 用 strict / basic / off 三级「检查级别」直接映射到目录,比 mypy 的逐项覆盖更直观。本机以 mypy 2.4.0 实测,pyright 部分请以官方文档为准。

分层的落地建议

把上面的策略固化成一张表,供你在真实项目里对照:

目录/模块建议档位理由
core/ domain/strict = true业务核心,错类型即事故
api/ services/strict = true对外契约,类型就是文档
adapters/ infra/关 disallow_untyped_defs第三方库缺标注,放宽但保留检查
legacy/ vendor/ignore_errors = true历史包袱,先隔离再逐步收编
tests/可放宽测试价值在行为,不在标注完备

配套一条纪律:遗留模块只减不增。新代码一律进严格目录,老代码逐步补标注、迁出豁免区。否则「临时豁免」会永久固化。

小结

  • pyproject.toml 分四段:[build-system] 管构建、[project] 管元数据与运行时依赖、[dependency-groups] 管开发依赖、[tool.*] 管各工具配置。
  • requires-python 既是安装门槛也是语法下限;dependencies 必须固定版本。
  • uv add --dev 写入 PEP 735 的 [dependency-groups],生产环境用 --no-dev 排除。
  • 类型检查不能全局一刀切:全局 strict 会淹没在噪音里,全局关闭则失去保护。
  • [[tool.mypy.overrides]] 支持按模块名做三档覆盖;实测证明宽松档仍能抓到真实类型错误,只有 ignore_errors 才真正跳过。
  • 遗留模块的豁免是过渡手段,配套「只减不增」的纪律才不至于永久固化。

配置定好了,但它还只是「写在文件里的标准」。下一节 1.3 代码规范、pre-commit 与提交门禁 要让这套标准在每次提交时自动执行——标准只有变成门禁,才真的拦得住问题。想深入了解依赖与打包的完整图景,可读专题 pyproject.toml 完全手册 。

阅读导航:上一节:1.1 从零搭建:uv + ruff + 静态检查 · 下一节:1.3 代码规范、pre-commit 与提交门禁 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「python」更多文章

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