工具与库 · 2026 年 11 月 11 日

Ruff 入门:用一个工具替代 Black、Flake8 和 isort

一个中等规模的 Python 仓库,通常会同时装着 Black、isort、Flake8 和四五个插件。Ruff 用一个二进制把它们收掉,但规则怎么选、边界在哪,比安装更需要想清楚。

很多 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 能用即可。

toml
[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"

日常命令就四条:

bash
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 不同。要打增量门禁,可以自己取变更文件再传给它:

bash
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 小。速度要自己量:

bash
# 缓存冷启动 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 收完所有历史欠账。

完

#Python#Ruff#代码规范#工具链
回到顶部 ↑