本节目标:读懂 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 与提交门禁 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。