《Python编程实战》1.3 代码规范、pre-commit 与提交门禁

写在文档里的规范没人执行,变成门禁才会拦人。本节先讲 ruff 规则集怎么选、format 与 --fix 的边界、per-file-ignores 怎么开合理的口子,再用原生 git hook 实测一个「提交前跑 ruff + mypy,不合格就拒绝提交」的门禁,并给出 pre-commit 框架的等价配置。

本节目标:把代码规范从「写在文档里」变成「提交时自动执行的门禁」;读完后你能配好 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 / Wpycodestyle缩进、空行、行长等 PEP 8 风格
FPyflakes未使用导入/变量、未定义名字
Iisortimport 排序与分组
UPpyupgrade过时写法升级到新语法
Bflake8-bugbear常见逻辑陷阱
SIMflake8-simplify可简化的写法
C4flake8-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 依赖解析与锁文件 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「python」更多文章

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