本节目标:把代码规范从「写在文档里」变成「提交时自动执行的门禁」;读完后你能配好 ruff 规则集与 format,并用 git hook 或 pre-commit 挡住不合格的提交。
适用版本:Python 3.12+(实测 3.14.6);ruff 0.16.10、mypy 2.4.0
1.3 代码规范、pre-commit 与提交门禁
前两节搭好了骨架、定好了配置,但标准还只是「写在文件里」。人在赶进度时最容易跳过检查——反正 CI 会跑,回头再改。等到 CI 红一片时,问题已经混进历史。本节要把标准变成门禁:不合格的代码根本提交不进去。
规范为什么要自动化
口头或文档规范有三个绕不过去的弱点:人会忘、标准会漂、新人不知道。自动化检查把这三条一起解决——规则是机器执行的,不会因为今天累了就放水;规则写在 pyproject.toml 里,是唯一的真相来源;新人第一天跑一次检查,就知道项目的标准是什么。
ruff 的规则集怎么选
ruff 内置了上千条规则,按前缀分组。你不需要全开,选几组覆盖「风格 + 常见 bug + 现代化」即可:
| 前缀 | 来源 | 管什么 |
|---|---|---|
E / W | pycodestyle | 缩进、空行、行长等 PEP 8 风格 |
F | Pyflakes | 未使用导入/变量、未定义名字 |
I | isort | import 排序与分组 |
UP | pyupgrade | 过时写法升级到新语法 |
B | flake8-bugbear | 常见逻辑陷阱 |
SIM | flake8-simplify | 可简化的写法 |
C4 | flake8-comprehensions | 推导式写法优化 |
配置写进 pyproject.toml:
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM", "C4"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"]
line-length = 100 是折中值:比默认的 88 宽松,比 120 更收敛,团队里争论最少。target-version = "py312" 让 ruff 知道你允许用 3.12 的语法,UP 规则据此决定「升级到哪一版」。
实测:一次检查抓到什么
拿一段典型的问题代码开刀:
import sys
import os
from typing import Optional
def find(name: Optional[str]) -> Optional[str]:
if name == None:
return None
data = []
for i in range(len(name)):
data.append(name[i])
return "".join(data)
def load(path):
f = open(path)
return f.read()
跑 ruff check(会读取上面那份配置):
ruff check svc.py
I001 [*] Import block is un-sorted or un-formatted
--> svc.py:1:1
F401 [*] `sys` imported but unused
--> svc.py:1:8
F401 [*] `os` imported but unused
--> svc.py:2:8
UP045 [*] Use `X | None` for type annotations
--> svc.py:6:16
E711 Comparison to `None` should be `cond is None`
--> svc.py:7:16
SIM115 Use a context manager for opening files
--> svc.py:16:9
Found 7 errors.
[*] 5 fixable with the `--fix` option (1 hidden fix can be enabled with the `--unsafe-fixes` option).
每条都精确到文件、行、列和规则编号。值得注意 UP045:它把 Optional[str] 升级为 3.10+ 的 str | None——这正是 target-version 决定的行为。E711 提醒 == None 应写成 is None,SIM115 提醒用 with 打开文件。
自动修复的边界
带 [*] 的 5 条可以自动修:
ruff check --fix svc.py
Found 7 errors (5 fixed, 2 remaining).
No fixes available (1 hidden fix can be enabled with the `--unsafe-fixes` option).
剩下两条修不了,因为它们需要改逻辑而非格式:E711 要改比较语义,SIM115 要把 open 包进 with。ruff 故意不动它们——自动修复只碰确定安全的改写,涉及语义的一律留给人。还有一类「隐藏修复」需要显式加 --unsafe-fixes,因为它们可能改变行为,ruff 默认不开。
format 则纯粹管排版,不碰语义:
ruff format svc.py
1 file reformatted
ruff format 基本兼容 Black 的风格,所以它替代了「团队争论用 Black 还是 yapf」这类问题——格式交给工具,人只管逻辑。
per-file-ignores:开合理的口子
一刀切的规则集总会在某些文件上误伤,per-file-ignores 用来开精准的口子:
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"] # 测试里允许 assert
"__init__.py" = ["F401"] # 包的导出导入不算「未使用」
"migrations/*" = ["E501"] # 自动生成的迁移文件不查行长
原则是只针对具体规则、具体路径开口子,绝不整体关掉某个目录的检查。__init__.py 的 F401 是经典场景:那里 import 是为了对外暴露名字,被当成「未使用导入」是误报。
pre-commit:提交前自动跑
有了检查命令,下一步是让它在提交时自动触发。业界标准工具是 pre-commit 框架,它把钩子声明写成 YAML,并自动管理钩子所依赖的工具版本。
本机未安装 pre-commit(也不允许临时安装),下面的配置为示意、未实测:
# .pre-commit-config.yaml(示意,本机未实测)
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.10
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v2.4.0
hooks:
- id: mypy
安装钩子用 pre-commit install,之后每次 git commit 都会先跑这些检查,失败则中止提交。它的优点是跨语言统一、版本自管理——团队里谁都不用先装 ruff,pre-commit 会自己拉一份到隔离环境。
实测:用原生 git hook 搭同样的门禁
不想引入 pre-commit 框架,也可以用 Git 原生的 hook 实现同样的效果。这在 CI 镜像、受限环境里更轻。我们实测一遍。
先建仓库并把钩子目录指过去:
git init repo && cd repo
mkdir -p .githooks
git config core.hooksPath .githooks
写一个 pre-commit 钩子,内容是「先 ruff、再 mypy,任一失败就退出非零」:
#!/bin/sh
set -e
echo "[pre-commit] ruff check ..."
ruff check .
echo "[pre-commit] mypy ..."
mypy .
set -e 是关键:任何一条命令返回非零,脚本立即中止,git commit 随之失败。给它可执行权限:
chmod +x .githooks/pre-commit
现在故意提交一段有问题的代码(bad.py 里 import os 但没用):
git add -A && git commit -m "add files"
实测输出——提交被挡下:
[pre-commit] ruff check ...
F401 [*] `os` imported but unused
--> bad.py:1:8
|
1 | import os
| ^^
help: Remove unused import: `os`
|
Found 1 error.
[*] 1 fixable with the `--fix` option.
注意 mypy 那行没有出现——因为 ruff 已经失败,set -e 让脚本在第一步就退出了。修好代码后重新提交:
git commit -m "add files"
[pre-commit] ruff check ...
All checks passed!
[pre-commit] mypy ...
Success: no issues found in 2 source files
[main (root-commit) dd755d0] 3 files changed, 11 insertions(+)
两道检查全绿,提交才真正落地。这就是门禁的本质:把「应该做」变成「必须做」。
门禁的三层结构
pre-commit 钩子只是第一层。完整的门禁是三层的:
| 层级 | 位置 | 作用 | 能否绕过 |
|---|---|---|---|
| 本地钩子 | git commit 前 | 快速拦截,即时反馈 | 能(--no-verify) |
| CI 流水线 | PR / push 时 | 强制校验,团队统一 | 不能(受保护分支) |
| 分支保护 | 仓库设置 | 挡住未过 CI 的合并 | 不能 |
本地钩子的定位是早发现、少等待,但它能被 git commit --no-verify 绕过,所以不能作为唯一防线。真正兜底的是 CI:同一套 ruff check + mypy 在流水线里再跑一遍,配合受保护分支,才能保证进主干的代码一定合规。
小结
- 规范只有自动化才有约束力;规则集中写在
pyproject.toml里,成为唯一真相来源。 - ruff 规则按前缀分组,选
E/F/I/UP/B/SIM/C4即可覆盖风格、常见 bug 与现代化升级。 ruff check --fix只做安全改写,涉及语义的(如E711、SIM115)留给人;--unsafe-fixes才放开有风险的修复。per-file-ignores只针对具体规则、具体路径开口子,绝不整目录关闭检查。- pre-commit 框架配置跨语言统一、自管版本;本机未安装,示例未实测。
- 原生 git hook 用
core.hooksPath+set -e即可实现同等门禁,本机实测可挡住不合格提交。 - 门禁是三层:本地钩子求快、CI 求全、分支保护兜底;本地钩子能被
--no-verify绕过,不能当唯一防线。
到这里,第一部分的「单项目工程基建」有了完整的第一块:环境、依赖、配置、检查、门禁。下一节 2.1 依赖解析与锁文件 会把依赖管理这块单独深挖——锁文件到底锁了什么、版本约束怎么选、冲突怎么解。想先看调试与日志相关的工程实践,可读专题 Python 调试与日志 。
阅读导航:上一节:1.2 pyproject.toml 全解与类型检查分层 · 下一节:2.1 依赖解析与锁文件 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。