很多 Python 仓库的 pre-commit 配置是这样的:Black 格式化一遍,isort 排一遍 import,Flake8 检查一遍风格,再加 bugbear、comprehensions、pyupgrade 几个插件。五个环境、五份配置、五次全仓库扫描。改一个缩进要等十几秒,CI 里这段跑在测试前面,慢得让人想跳过它。
Ruff 把这些收进一个 Rust 二进制:既是 linter,也是 formatter。但「替代」两个字容易让人低估迁移成本,尤其是规则集和格式化行为上的差异。
它对应的是哪几个工具#
| 原来的工具 | Ruff 里的对应 | 说明 |
|---|---|---|
| Black | ruff format |
格式化,与 Black 高度接近但不逐字节相同 |
| isort | ruff check --select I |
import 排序,兼容主要 isort 配置项 |
| Flake8(E/F/W) | ruff check |
pycodestyle + pyflakes 的核心规则 |
| flake8-bugbear | 规则前缀 B |
常见陷阱检查 |
| pyupgrade | 规则前缀 UP |
老语法升级建议 |
| flake8-comprehensions | 规则前缀 C4 |
推导式简化 |
| pydocstyle | 规则前缀 D |
docstring 规范,默认不启用 |
对应关系不是一一映射:Ruff 按规则前缀组织,一个前缀往往覆盖几个插件的部分规则。迁移时按「行为」对齐,不要按工具名对齐。
接进项目#
配置放 pyproject.toml,和项目其他元数据待在一起。安装方式(pipx、uv tool、包管理器)以官方文档为准,装完确认 ruff --version 能用即可。
[tool.ruff]
line-length = 100
target-version = "py310"
src = ["src", "tests"]
[tool.ruff.lint]
# 从这几组开始,够用且噪音低
select = ["E", "F", "I", "UP", "B", "SIM"]
# 行长交给 formatter,别让两条规则互相打架
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"] # 包的公开接口需要 re-export
"tests/**" = ["E402"] # 测试里常调整 sys.path,import 不一定在顶部
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
日常命令就四条:
ruff check . # 只报告,不改文件
ruff check --fix . # 修可自动修的,剩下的留给人
ruff format . # 格式化
ruff check --diff . # 打印将要发生的改动,适合在 CI 里做门禁
CI 里建议分开两步:ruff format --check . 管格式,ruff check . 管规则。两者失败原因不同,混在一步里排查会绕路。
两条 --fix 的顺序也有讲究:先 ruff check --fix(import 排序、语法升级这些结构性改动),再 ruff format 收尾,因为前者改完的代码往往要重新对齐缩进。把这套顺序写进 Makefile 或 justfile,比靠记忆可靠。
第一次 ruff format 会产生一个覆盖全仓库的 diff。把它单独做成一个 commit,写清「只做格式化」,不要和业务改动混在一起,否则 review 会变成灾难。
渐进迁移:先只读,再只修#
一次性全量格式化在老仓库里几乎不可行。更现实的做法分三步。
第一步只报告。CI 里加一条 ruff check .,先允许失败,看告警分布在哪。用 ruff check --statistics . 比默认输出有用得多:它按规则汇总条数,一眼能看出该先修哪一类。告警通常集中在两个地方——早期没人管的应用目录,和自动生成的代码。
第二步限定范围。extend-exclude 排掉生成代码,per-file-ignores 给历史包袱重的目录降级,只要求新代码路径全绿。
第三步收紧。新代码稳定后逐个目录修历史文件,每次只动一个目录,diff 可控,出问题好回滚。
Ruff 没有内建的「只检查本次改动」模式,这点和 ESLint 的 ratchet 不同。要打增量门禁,可以自己取变更文件再传给它:
git diff --name-only --diff-filter=ACM origin/main... \
| grep '\.py$' \
| xargs -r ruff check
钩子层面也跟着变简单:pre-commit 里只留两个 hook,一个 ruff-check --fix,一个 ruff-format,不再需要为 Black、isort、Flake8 各装一套环境。提交时的等待从十几秒压到一两秒,人才愿意留着它。
规则选集比工具选择更重要#
换工具只是一次性成本,规则选错是长期成本。几个实际经验:
- 别一次全开。
select = ["ALL"]看起来很爽,第一周会产出上千条告警,然后所有人开始加# noqa,规则形同虚设。按E/F/I→B/UP→ 项目相关组(Django 用DJ,pytest 用PT)的顺序加。 noqa必须带原因。# noqa: E501是噪音,# noqa: B008 - FastAPI 的 Depends 需要函数调用才是给人看的。团队里可以约定:没有说明的noqa在 review 时会被要求补上。- 忽略项写具体。忽略
E501(行长)而不是忽略整个E组,前者是有意的取舍,后者是关掉了一整类检查。 - 和已有配置对齐。如果项目原来是 isort 的
force_sort_within_sections,就打开对应的force-sort-within-sections,否则 import 会被重排成另一套顺序,凭空多出几百行改动。
「替代」的边界在哪里#
Ruff 不能干的事,最好提前知道:
| 需求 | Ruff 能否覆盖 | 建议 |
|---|---|---|
| 类型检查 | 不能 | 继续用 mypy 或 pyright |
| 自定义规则 / 第三方插件 | 不支持外部插件 | 用内置规则近似,或保留 pylint、写独立脚本 |
| 跨文件重命名、重构 | 不能 | 用 IDE 或 rope 这类工具 |
| 与 Black 逐字节一致 | 不保证 | 迁移时接受一次性 diff,之后不再混用 |
| 实验性规则 | 部分在 preview 里 | 需要显式开启,版本升级时关注变更 |
最容易被忽略的是插件生态。Flake8 的插件是开放的,团队内部写的检查可以挂上去;Ruff 只提供内置规则,这条路的替代方案是把它做成独立的 AST 脚本,接在 CI 的另一条命令里。
还有一点:Ruff 快,但不等于可以把 lint 塞进文件保存钩子后无限扩张。规则越多、文件越多,耗时线性增长,只是斜率比 Flake8 小。速度要自己量:
# 缓存冷启动 vs 热启动,各跑三次
time ruff check --no-cache .
time ruff check .
如果冷启动要十几秒,说明规则集太宽或者目录没有排除干净(exclude 里补上 build/、node_modules/、生成的迁移文件)。缓存命中后的耗时才代表日常开发体验。
版本策略也属于边界的一部分。Ruff 的规则集会随版本调整:有的默认值变了,有的从 preview 转正,有的拆成两条。CI 里固定版本,升级时单独开一个 PR 跑全量检查,看清新增了哪些告警再决定接受还是忽略。别让它跟着 pip install -U 悄悄变。
结论#
- 新项目直接上
ruff check+ruff format,配置写进pyproject.toml,别再装 Black 和 isort。 - 规则从
E/F/I起步,按需加B/UP/PT,不要开ALL。 - 类型检查留给 mypy/pyright,Ruff 不管这件事。
- 格式化单独一个 commit,
noqa必须写原因。 - 需要第三方插件或自定义规则时,接受 Ruff 不覆盖,另挂一条检查,而不是放弃整套工具。
- 老仓库从「只报告」开始,按目录推进,别指望一个 PR 收完所有历史欠账。
完