如果你的项目在经历了某个“平静的周一”之后,突然被一条几百个文件的 lint 流水线卡了十分钟,你可能也会像我一样开始怀疑人生。那时候我们用着一套标准的 Python 工具组合:Flake8 管检查、Black 管格式化、isort 管 import 排序,听起来挺完整,但实际跑起来就是一个字:等。直到我换成了 Ruff,才发现原来这些事可以在一套工具里、用接近零延迟的速度一次全部搞定。
这篇文章不是官方文档搬运,是我把一套百万行代码库从传统工具链切到 Ruff 的完整复盘。我会讲清楚为什么换、怎么迁、配置怎么设计、规则怎么选、哪些坑踩了又怎么填,适合正在纠结要不要迁移的团队,也适合想从零开始规范 Python 代码的个人开发者。如果你只是想找一个“更快一点的 linter”,那这篇同样适用。
1. 老工具链的疲惫:不是慢的问题,是心智负担太重
1.1 传统工具组合的日常:Flake8、Black、isort 各管一摊
先交代一下我接手项目时的状态。代码量已经膨胀到接近一百万行,分布在几十个子项目里,Python 版本从 3.8 到 3.11 混杂,每个子项目的配置还不一样。检查环节当时是这样的:Flake8 负责语法静态检查,isort 负责 import 排序,Black 负责格式化。这三个东西各有个的配置文件,Flake8 要.flake8或setup.cfg,isort 要[tool.isort],Black 要[tool.black],三份配置规则有时候还对不上。
最典型的是 import 排序。isort 默认按照 stdlib、第三方、本地包分组,Black 不管分组只管换行和引号风格,两者在某些极端缩进场景下会互相触发对方的改动。你刚用 Black 格式化完,isort 再跑又会往文件头上加注释;isort 调整完顺序,Black 又觉得某个 import 该拆成多行。于是我们当时的 CI 流水线里经常出现“同一批代码,check 永远输出不一致”的诡异状态。
Flake8 本身也不是没有毛病。它本质上是 pyflakes 加 pycodestyle 的一套“组装机”,里面不少规则是上世纪就定义的代码风格。很多规则在今天的工程实践里已经过时,但默认配置全开的时候照样会报。比如E501行长度限制,默认 max-line-length=79,这在老时代是硬性标准,现代项目 88、100、120 都很常见。你每次都要在配置里改一堆参数,然后等几秒钟它跑完一条消息,再改再跑。
1.2 速度问题只是表象,真正的麻烦是规则分裂
当时我们的 lint 流程分两段。第一段走 CI,对所有改动文件跑 Flake8;第二段是本地 pre-commit,跑 isort、Black、Flake8 三件套。上一代工具链在本地小改动还能忍受,但一旦是全量检查,Flake8 跑完整个项目要两分钟左右。在代码量小的时候,两分钟可以接受;当代码量涨起来,加上 isort 和 Black 串行执行,一次完整检查十分钟就出去了。人一多,排队等你本地 pre-commit 通过的场景频繁出现。
最让我崩溃的并不是慢,而是规则分裂这件事本身。Flake8 的规则代码以E、W、F开头,用来表示风格问题和逻辑问题;isort 不跟自己归一类;Black 则是“没有规则代码,只有格式化”。这意味着你要记住几套代码体系,还要在各种配置里反复横跳。讨论一个问题的时候,团队成员可能要同时打开三个配置文件和四份文档,这个心智负担早就超过了“检查代码是否规范”应有的成本。
所以我当时在团队周会上提出一个想法:如果有一工具,能把检查、排序、格式化全部聚合,配置只写一处,并且用 Rust 重写、速度提升几十倍,我们是不是就该换?那就是 Ruff 最初打动我的地方。它不只是一个 linter,它把 pycodestyle、pyflakes、isort、pyupgrade、bugbear 的规则全部重新实现了一遍,并且统一用一套规则代码对外输出。
2. 先装起来再说话:Ruff 的安装、命令行和第一印象
2.1 安装方式与版本选择
如果只是试用,最简单的办法是直接通过 pip 安装:
pip install ruff我的建议是尽量用固定版本,不要用ruff==0.x这种模糊约束,因为它迭代非常快,默认规则集每隔几个版本就会变。我实际项目里用的是某 0.9 版本之后的版本,锁死在一个具体的小版本上,避免同事之间环境不一致导致检查结果不同。
如果你是给多个 Python 项目统一管理,推荐用 pipx 安装成独立工具:
pipx install ruff这样它不污染当前虚拟环境,又能全局复用。如果项目中已经引入了 uv,也可以用uv tool install ruff,原理类似。
装完先确认版本:
ruff --version这时候你会发现它的启动输出快得离谱。我这里不是夸张,Ruff 启动基本在几十毫秒级别,因为它用 Rust 编译成单个二进制,没有 Python 解释器加载的额外开销。
2.2 两条主命令:check 与 format 的日常分工
Ruff 的命令行体系很干净,日常用到的就两个:ruff check和ruff format。
ruff check承担的是传统 lint 的职责:查语法错误、未定义变量、未使用导入、PEP8 风格问题、高版本语法风险等。它支持--fix自动修复一部分问题,也支持--diff先预览改动。ruff format承担的是格式化职责,对标 Black,用来统一代码风格。
我第一次跑ruff check .是在一个历史项目上,输出直接刷屏,因为它把几千个问题都列了出来。当时的直观感受是“哇,真的快”,但也意识到迁移不是直接开全量规则就能上线的,得渐进式处理。所以第一步我建议你只跑默认规则:
ruff check .Ruff 默认只开 E4、E7、E9 和 F 这两大类规则,也就是那些容易造成 bug 的语法风格和逻辑问题。这个默认策略很聪明,它避免了初学者一上手就被几百条风格噪音淹没。
如果你只想让某个目录参与检查:
ruff check src/ scripts/如果想自动修复并同时显示修复了哪些文件:
ruff check . --fix还有一条我日常工作里最常用的命令:
ruff check . --fix --diff--diff不会真正修改文件,只输出将要修改的内容,适合放到代码评审里给同事看改动影响。
2.3 第一次运行就能看到差距
我做过一个对比记录:同样一个包含约 2900 个 Python 文件的旧代码库,老流程 Flake8 全量检查耗时约 130 秒,isort 加 Black 各跑一遍约 40 秒,整个 pre-commit 串行完成大概 170 秒。换到 Ruff 之后,ruff check .只需要不到两秒,ruff format --check .不到一秒。
这个差距不是“优化”能追上的,是机制上的差异。Ruff 内部用并行任务队列扫描文件,单个文件的分析也非常轻量,因此在大仓库下优势会指数级放大。后面我会专门讲迁移过程中的实测数据,这里只给你一个判断:如果你的 lint 流程开始成为团队提交代码的瓶颈,这就是你认真考虑迁移的信号。
3. 配置文件的学问:pyproject.toml、select、target-version
3.1 配置放哪里,以及为什么推荐 pyproject.toml
Ruff 支持三种配置放置方式:pyproject.toml里的[tool.ruff]段、根目录的ruff.toml、以及生态里常见的.ruff.toml。我的意见是:只要你的项目已经在用现代 Python 项目结构,就把它放进pyproject.toml。
理由很直接:pyproject.toml已经是事实上的项目元数据标准,黑盒、isort、mypy、pytest 全都可以在这里声明配置。把 lint 配置也放进来,所有工具配置集中在一个文件里,新成员上手只需要看一个文件。
一个最小配置大概是这样的:
[tool.ruff] target-version = "py39" line-length = 88 src = ["src", "tests"] [tool.ruff.lint] select = ["E", "F", "I", "UP", "B"]target-version很关键。它告诉 Ruff 项目最低支持的 Python 版本,Ruff 会根据这个版本决定哪些语法会被标记为不兼容。比如项目声明支持 Python 3.9,那代码里如果用了 3.10 才有的match语法或者X | Y类型合并写法,Ruff 就会警告。
line-length我一般直接沿用 Black 的 88 或 100。如果你之前用过 Black,直接把line-length保持与原 Black 配置一致,这样迁移格式化时影响最小。
3.2 规则选择的渐进式策略
Ruff 的规则体系不是一堆堆散件,它把规则按“前缀”归类,这个前缀和社区工具对标。我建议第一次接项目时不要急着把所有规则全开,而是分阶段扩展。考虑如下渐进式策略:
- 第一阶段:
["E4", "E7", "E9", "F"],这是默认行为,主要查逻辑缺陷。 - 第二阶段:加上
"E"、"W",全面启用 PEP8 风格检查,同时加"I"做 import 排序。 - 第三阶段:按项目需要加
"UP"(pyupgrade,检查老语法)、"B"(bugbear,潜在 bug)、"SIM"(简化写法)、"S"(安全风险)等。
每一个阶段都应该配合ruff check . --fix自动修复一部分,剩余问题人工过一遍。这样不会出现换工具第一天就被几千条告警淹没的情况。
这里还要说明一下规则的“分级修复”机制。Ruff 对每一条能够自动修复的问题都有标记:有的可以安全自动改(比如删除未使用的 import),有的需要人工确认(比如改变代码语义的简化)。它的--fix默认只处理安全级别为“安全”的修复,不会强行把可读性改成有风险的写法。这个设计在迁移大仓库时非常友好。
3.3 用 per-file-ignores 处理历史包袱
大仓库迁移时你一定会碰一个场景:某个老模块的代码风格极其混乱,但它运行稳定,没人敢大改。这时候不建议直接noqa加到每一行,更优雅的方案是用per-file-ignores,把特定目录的某些规则直接忽略。
[tool.ruff.lint.per-file-ignores] "migrations/*" = ["E501"] "src/legacy/*" = ["I", "UP"]这意味着新代码严格遵守全量规则,历史包袱代码先保留现状,后续重构时再逐步解除豁免。这种差异化处理比一刀切全量开启更符合真实工程节奏。
4. 规则代码背后在管什么:从 E/F 到 I、UP、B、S、SIM
4.1 核心规则族一图看懂
很多人第一次看 Ruff 的规则列表会觉得头大,E、F、I、N、UP、B、S、SIM、T20、ANN、RUF……前缀太多。我整理了一张我团队内部通用的对照表,你选规则时可以照着挑。
| 前缀 | 对标工具 | 主要管什么 | 典型问题示例 |
|---|---|---|---|
| E / W | pycodestyle | PEP8 风格 | 多行缩进错误、行尾空格、空行数量 |
| F | pyflakes | 逻辑与语法缺陷 | 未使用导入、未定义名称、重复赋值 |
| I | isort | import 排序 | 标准库/第三方/本地分组错误 |
| N | pep8-naming | 命名规范 | 变量名下划线风格、类名大驼峰 |
| UP | pyupgrade | 高版本语法迁移 | str.format改 f-string、Decimal旧写法 |
| B | flake8-bugbear | 潜在 bug 模式 | 函数默认参数可变对象、裸except: |
| S | bandit | 安全风险 | 使用不安全的eval、硬编码密钥 |
| SIM | flake8-simplify | 简化写法 | 能写if x:却写成if x == True: |
| T20 | flake8-print | print 调试残留 | 生产代码出现print() |
| ANN | flake8-annotations | 类型注解 | 函数参数缺失类型注解 |
这张表不用背,当你需要决定开哪些规则时,对着它挑项目真正需要的就行。例如内部项目不接受安全审计类告警剧增,就先不开 S;老代码大量缺类型注解,ANN 就建议先放着,等后续逐步补。
4.2 几个高频规则的实测案例
脱离场景聊规则没意义,我选三个实际项目里出现频率最高的规则类型具体说一下。
第一个是F401,未使用的 import。它几乎是每个项目里都能扫出一堆的问题。Ruff 对F401的自动修复很成熟,会在删除 import 的同时检查是否因为 re-export、动态导入等原因需要保留。它识别__init__.py里专门用于导出的内容,也不会误删。这个规则建议在任何项目里都开。
第二个是I001,import 块排序问题。传统 isort 会要求你按字母序排 import,但很多项目实际写代码时是“顺手添加”,并不在意顺序。Ruff 的--fix能自动整理整个 import 块,并且不会和它的 formatter 冲突。因为 import 排序规则本质上就是“格式化代码”,你不需要再单独跑 isort。
第三个是UP032/UP034,f-string 迁移类问题。Ruff 会识别哪些%格式字符串可以安全地转为 f-string,哪些不能(比如包含动态格式表达式)。自动修复之后,代码可读性会有明显提升。我遇到过的最典型场景是:
# 修复前 message = "Order %s is pending" % order_id # 修复后 message = f"Order {order_id} is pending"这种改动几乎没有风险,但它能极大改善代码可读性,新人扫代码时反馈“以前看字符串拼接要反应一阵子,现在一眼就懂”。
4.3 规则之间会打架吗?select 和 ignore 的优先级
规则之间确实有可能出现重叠,但 Ruff 对这一块处理得比较好。它的原则是:select是允许集合,ignore是集合内剔除;per-file-ignores在ignore之后再叠加。实际用起来,你可能会遇到两个规则对同一行代码给出互相矛盾的修改建议,这时处理方式是看你更偏向哪种规范。
比如B008(函数参数默认值使用可变对象,建议None哨兵)和SIM系列的某些简化规则,在少数极端代码里会产生冲突。Ruff 官方认为这种情况少见,但我个人建议在配置里显式声明所有与格式化风格强相关、但又容易误伤的规则代码,方便以后调整。
我的配置习惯是,ignore尽量少,用来精确排除某条规则;需要排除整个目录的,用per-file-ignores。不要为了少看告警直接ignore = ["ALL"],那就失去了 linter 的意义。
5. 格式化不是一个简单的“自动排版”:Ruff format 与 Black 的兼容关系
5.1 format 解决什么问题,不解决什么问题
很多人把 “format” 和 “lint” 混为一谈,这是迁移时最容易踩的坑。Ruff 的内部结构把它们设计成了两套独立模块:ruff format负责把代码重新排版成统一风格,相当于 Black 的替代品;ruff check负责检查规则,相当于 Flake8/其他插件的替代品。你可以只用 format 只用 check,也可以两个都用,但它们在 CI 里应该各跑各的。
ruff format不检查“该不该有类型注解”“有没有安全风险”,它只关心代码长成什么样。它解决的问题包括:单行长度是否超线、括号缩进是否一致、空格是否需要调整、表达式要不要拆行。它不会改变任何运行逻辑,这是格式化工具必须守住的底线。
但你要注意,它也不是“能把所有丑陋代码变漂亮”的魔法。比如你有一个嵌套了七八层的函数,代码逻辑本身就绕,格式化之后依然是嵌套七八层。format 只能让绕的代码绕得一致,不能替你重构。
5.2 与既有 Black 格式化代码的迁移兼容
如果你现在项目里已经用 Black 格式化了很久,切到ruff format的体验通常会相当平滑。因为 Ruff 的 formatter 在设计之初就是以兼容 Black 为目标的。我测试下来,绝大多数现有 Black 格式化代码切换到 Ruff format 后不会产生大量 diff。
但有两个地方需要留意。
第一是引号风格。Black 默认把双引号字符串统一成双引号,Ruff format 也遵循这个默认。如果你之前用的是单引号统一策略,切换后会有大量字符串被改动,建议迁移前在 CI 里跑一次ruff format --check .看看到底有多少改动,再决定是否接受。
第二是魔法尾逗号处理。Black 在某些场景下会根据末位逗号决定拆行,Ruff format 也有类似逻辑,但细节上可能有细微差异。这种差异通常出现在函数参数列表极长、最后一个参数后带逗号的场景。遇到这种输出不一致,我的建议是不要去逐条 hack 特殊写法,直接接受工具的决策,团队统一标准。
5.3 检查与格式化在 CI 里的正确姿势
我们团队现在 CI 里的姿势是这样:
ruff check . --fix --diff ruff format --check .第一行检查 lint,并展示将来自动修复的内容;第二行只验证格式化状态,不做实际修改。为什么不用ruff format .直接改?因为 CI 里直接改动文件会污染流水线产物,而且代码没有经过本地 commit,直接改动会让开发者困惑。让每一个人在本地通过 pre-commit 完成格式化,CI 只验证状态,是最干净的工作流。
这里有个很关键的经验:不要在 CI 里跑ruff check . --fix然后期待它把文件修好。修复应该发生在开发者本地,CI 是门禁,不是施工队。
6. 编辑器、pre-commit 与 CI 集成:让工具真正跑起来
6.1 pre-commit 钩子配置
如果你的项目已经有 pre-commit,那接入 Ruff 几乎是最顺的一步。在仓库根目录的.pre-commit-config.yaml里加一段:
repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.9.0 hooks: - id: ruff args: [--fix] - id: ruff-format注意两点。第一,rev要和你本地装的实际版本保持一致,否则会出现“本地跑不过、pre-commit 跑过”或反过来的诡异情况。第二,ruff钩子带--fix会在 commit 前自动修改文件,所以所有自动修复的改动仍然需要你加进暂存区。
如果你还没有引入 pre-commit,我依然建议加上。它的意义不仅是 CI 之前多一道防线,更重要的是它把“代码风格统一”从人工评审中剥离出来,让人去关注逻辑,而不是争论“这里该不该折行”。
6.2 编辑器集成与实时反馈
Ruff 在编辑器里的体验,是它让我彻底回不去旧工具链的原因之一。在 VS Code 里装对应插件后,它能做到四件事:实时诊断、悬停显示规则说明、保存时自动格式化、快速修复上下文菜单。这个体验接近前端 ESLint 的体验:你一边写,它一边告诉你哪块有问题。
我习惯把编辑器配置成“保存时格式化”,并设置默认格式化器为 Ruff。这样不再需要手动记快捷键,写代码本身就是规范化的过程。对于不熟悉命令行的刚入门同学,这几乎是一种零学习成本接入方式。
有一点要注意:编辑器里如果同时装了多个 Python 格式化插件,一定要在设置里把默认格式化器明确指定为 Ruff,否则会出现“多个格式化器抢文件”的混乱。我会建议把其他旧工具对应的插件直接禁用掉,保持环境干净。
6.3 CI 流水线里的两个阶段
除了 pre-commit,CI 阶段也需要独立的 Ruff 任务。原因是 pre-commit 只在开发者本机触发,推送代码后仍需在流水线里验证一次。我建议把检查拆成两个独立 job,或者至少是两个独立步骤。
第一阶段跑 lint:
ruff check .第二阶段跑格式验证:
ruff format --check .这两个阶段为什么要拆开?因为告警类型不同,排查路径也不同。lint 告警告诉你“这行代码有什么隐患”,需要开发者去看规则;format 告警告诉你“这行代码排版不合规”,基本可以直接运行ruff format解决。混在一起会让失败日志变得冗长且难以定位。
如果公司 CI 平台支持缓存,建议给 Ruff 开启缓存目录缓存。Ruff 默认会在系统临时目录建缓存,二次运行时如果文件没变化,它直接读缓存,速度会更快。在 CI 场景下这能省几秒到几十秒,看仓库规模。
7. 真实迁移复盘:从 2900 个文件到全量零告警
7.1 迁移路线与阶段划分
这里分享一下我实际迁移某个大仓的准备过程。这个仓有大约 2900 个 Python 文件,代码历史悠久,规则欠债多。我没有选择“一步切全量”,而是分了三个阶段。
第一阶段是“观察期”。我把 Ruff 加入配置,但select只开默认规则,也就是 E4、E7、E9 和 F。这个阶段跑了两周,目的是让团队先习惯 Ruff 的命令行和输出风格,积累一部分自动修复经验。由于默认规则报的基本都是真实缺陷,这次修复让仓库质量有了可感知的提升。
第二阶段是“风格期”。我把E、W、I开起来,同时启用ruff format作为唯一格式化工具。这阶段的主要工作是处理存量代码的格式不统一和 import 排序问题。因为改动量大,我先跑了ruff format --check .导出一份未格式化文件清单,再分批让模块负责人认领处理。
第三阶段是“增强期”。我逐步加入UP、B、SIM,根据团队承受能力控制每次新增的规则数量。每次优先跑--fix处理安全修复项,剩余需要手改的通过per-file-ignores暂缓。大约六周后,全仓库达到ruff check .零告警的状态。
7.2 数据对比:时间、规则数量、代码改动行数
迁移前后的对比数据可以给准备迁移的人一个参考。表格里是我在自有仓库里记录的数字,你按自己仓库规模等比估算就好。
| 指标 | 迁移前 | 迁移后 |
|---|---|---|
| 全量 lint 耗时 | 约 150 秒 | 约 2.3 秒 |
| 格式化耗时 | 约 40 秒 | 约 1.1 秒 |
| 涉及规则来源 | 4 个独立工具 | 1 个工具统一管理 |
| 配置文件 | 3 份分布在多目录 | 1 份 pyproject.toml |
| 存量告警数 | 数千条无法统计 | 0(按当前规则集) |
| 代码改动行数 | — | 约 8% 文件被格式化触碰 |
8% 看起来不大,但放到 2900 个文件里也有两百多个文件被改动过。这个量级如果直接在主干分支操作,会严重影响协同,所以我强烈建议迁移改动走独立分支,并尽量用批量 PR 合入。
7.3 迁移过程中的“拆弹”策略
迁移过程里最危险的不是规则告警本身,而是“全仓一次性改动”带来的合并冲突。我这里的策略是:把格式化改动和 lint 修复分开提交,格式化改动主要出现在独立分支,lint 修复按模块拆成多个小 PR,每个 PR 只改一个子项目的相关告警。这样 reviewer 看到的信息是干净的,也方便回滚。
另一个拆弹策略是“白名单与黑名单结合”。团队里总有那么一两个模块是没人愿意动的,我用per-file-ignores把它们排除在部分规则之外,同时给这些模块开一个单独的跟踪 issue,定期把豁免范围缩小。半年后再看,这些“历史遗留”区域已经被蚕食得差不多了。一刀切不可取,渐进式蚕食更容易落地。
8. 我踩过的坑与规则禁用的艺术
8.1 版本升级带来的默认规则变化
Ruff 迭代很快,它每隔一段时间就会把某些规则从“预览”状态转为正式默认启用。这带来一个实际问题:你锁定的版本在一个月后可能因为升级而突然出现一批新的告警。我的建议是两点:第一,CI 环境里锁死版本号;第二,升级时专门跑一次ruff check .,把新增告警分类,能自动修复的直接修,需要讨论规则的单独开 PR 讨论。
我经历过一次升级后突然出现几十个T201(print 语句)告警,就是因为新版把 print 规则从可选变成了推荐。这事本身不坏,但它提醒我,升级工具不能像升级普通依赖那样不看 changelog 直接发版。
8.2 noqa 与批量忽略的正确姿势
不管规则设计得多好,总有“这行代码就是该特殊处理”的情况。Ruff 支持在行尾加# noqa: CODE来忽略单行告警,也支持用# noqa忽略整行所有告警。我的原则是:必须带具体规则代码,不要用裸noqa。原因很简单,同样的行,只忽略某一条规则可读性好,也会让后续维护者知道这里为什么豁免。
如果存量代码已经有一堆需要忽略的地方,Ruff 提供一条实用命令:
ruff check . --add-noqa它会把当前所有无法自动修复的告警自动加上对应noqa注释。这条命令适合迁移第三阶段冲刺时用:先把存量清零,之后新代码不再允许新增裸 noqa。我一般会再加一条.git-blame-ignore-revs之类的机制把批量改动从 blame 里隐藏,方便后续真正重构时回溯。
8.3 不是所有规则都值得开:几个我最终放弃的规则
这里说一点反直觉的经验。虽然 Ruff 很强大,但我最终没有开启它的全部规则。有几个规则在实际项目里给我带来的困扰大于价值。
第一个是ANN(类型注解)。全量开启它会让老代码陷入“注解地狱”,新人也容易为了满足注解规则而乱写类型。我的做法是只在新增文件或核心模块中启用它,历史文件暂不要求。
第二个是S(安全规则)里的部分检查。像S324这类关于哈希算法的规则,它在某些非安全敏感场景会误报很多,团队每天都要处理“伪安全问题”。我会在配置里精确地排除掉这类规则,保留真正有意义的S101(断言误用)等。
第三个是SIM里的部分简化建议。有些简化建议在“可读性”角度并不是绝对正确,比如建议把if x is not None改成if x,这在某些语义下会让代码意图变得模糊。工具是助手,不是老板;规则的开关权应该掌握在团队手中。
最后再分享一点我的实操体会
我自己折腾完这一圈之后,最大的体会是:Ruff 不是简单的“平替”,它的价值在于把多工具分散的心智负担收拢成一个东西,让团队在代码规范上形成一套统一语义。你不需要再解释“这是 isort 的错,那是 Black 的偏好,Flake8 和它俩配置冲突”,只需要说“Ruff 规则是什么”。
如果只能给一条建议,我会说:不要试图在第一天把所有规则都打开,先默认规则跑一周,再按周递增规则族。很多项目迁移失败,不是 Ruff 不够好,而是迁移策略太激进。先让团队尝到“快”的甜头,再把“严”逐步加上去,你会发现代码质量这件事并没有想象中那么痛苦。