我差不多每隔两个月就要被同事拉去看一次 VSCode 的格式化问题——现象千奇百怪,但根因高度集中:要么扩展装了三个互相打架,要么settings.json和pyproject.toml里各写了一份行宽、值还不一样,要么 CI 上跑的和本地跑的根本不是同一个工具版本。所以这篇就把VSCode Python 自动格式化配置从头捋一遍,从Black怎么配、为什么这么配,到迁到Ruff之后哪些行为会变、哪些坑会冒出来。不管你是刚开始建仓库、还没配过保存自动格式化,还是手上有个跑了两年的项目想从 Black 换 Ruff 又怕把 diff 炸了,下面这些内容都值得花二十分钟看完再动手。我尽量把每一步的"为什么"讲透,因为格式化这东西,抄配置十分钟,搞懂机制能省你未来两年的返工。
1. 先把这笔账算清楚:为什么值得花半小时配好
1.1 Black 解决的问题,和它没解决的问题
Black 当年能迅速统一社区审美,靠的不是它格式化得"最好看",而是它把"讨论成本"降到了零。它几乎没有可调参数,团队里没人能就"括号该不该换行""这一行该不该断"吵起来——工具说了算。这个设计决策看起来霸道,实际极其省事:代码评审里再也不会出现纯风格的 diff 噪音,新人提 PR 不用先背一本内部风格手册。
但 Black 只管一件事:把代码的空白和换行调整成统一形态。它不管 import 顺序,不管你没有用到的变量,不管你是不是把可变对象当默认参数传进去了。所以一个典型的 Python 项目在 Black 之外,还得再挂 isort 排 import、挂 flake8 或 pylint 查问题、挂 pyupgrade 改老写法。四个工具、四份配置文件、四条 pre-commit 钩子,每个都有自己的行宽参数和排除规则——黑名单写四遍,改一次漏三处,这事儿我干过不止一次。
更麻烦的是速度。这几个工具虽然都能在一个大仓库里跑,但逐个启动、逐个解析文件,全量跑一遍几十秒起步。到了 CI 上,为了省时间很多人干脆只在改动的文件上跑,结果就是仓库里一半文件是格式化过的、一半没有,diff 越来越脏。
1.2 Ruff 把三件事压成一件
Ruff 的定位很直白:用一个工具同时干掉 lint、import 排序、格式化三件事,底层用编译型语言实现,跑起来比解释型的那几个快一个量级。我这边一个中等规模的仓库,全量 Black + isort + flake8 跑完要十几秒,换成ruff check --fix加ruff format之后,加起来通常一两秒内结束,冷启动也快得多。
实际配置层面,它的价值在于收敛:一份pyproject.toml里[tool.ruff]、[tool.ruff.lint]、[tool.ruff.lint.isort]、[tool.ruff.format]四段,把原来四份配置全收了。黑名单只写一遍,行宽只写一遍,# noqa和# fmt: off的语义也是同一套。团队交接的时候,新人只看一个文件就知道代码规范是什么。
代价当然也有,得说清楚:Ruff 的 lint 规则集虽然覆盖面很广,但和 flake8 插件生态不是完全对齐的,某些小众插件(尤其是跟框架强绑定的那些)它没有对应实现;格式化部分对 Black 的兼容目标是"绝大多数情况下输出一致",不是 100%。剩下的那点差异通常集中在注释位置的取舍、长链式调用的括号布局、以及 docstring 内部的代码块处理上。这些差异对一个从零开始的项目毫无影响,对一个有几千个文件的老仓库,就意味着迁移那一下会产生一批非语义的 diff。
1.3 选型对照:什么仓库该用哪个
我把常见的几种情况列一下,你可以直接对号入座,不用纠结。
| 场景 | 建议方案 | 理由 |
|---|---|---|
| 全新仓库,从第一个 commit 开始 | Ruff 全套(lint + format) | 没有历史包袱,一份配置到底,最省心 |
| 老仓库,已经全量 Black 过 | Ruff format,先跑一次对比 | 差异通常很小,一次全量提交即可收敛 |
| 团队严重依赖 flake8 特定插件 | Black 保留,Ruff 只做 format | 别为了统一工具牺牲已有的检查能力 |
| 仓库里有大量 Notebook | 两者都行,但必须单独配 Notebook 作用域 | 默认配置对.ipynb不生效 |
| 多人协作、CI 严格 | 任何方案都行,关键是锁版本 | 工具版本不一致才是真凶 |
| 只想解决保存自动格式化 | Black 上手最快 | 装一个扩展、写三行设置就完事 |
最后一行值得单独说一句:如果你的诉求只是"保存的时候帮我把代码排整齐",Black 依然是路径最短的选择,一个扩展加三行配置就够了。Ruff 的收益要在你同时需要 lint 和 import 排序的时候才真正显现出来。
2. VSCode 保存格式化的三层机制
2.1 语言服务器、格式化扩展、保存钩子各管什么
很多人配不明白,是因为把三个独立的东西当成一个了。VSCode 里跟 Python 格式化相关的角色其实分三层。
第一层是编辑器自身的保存钩子。editor.formatOnSave这个开关属于编辑器核心,它不关心你用什么语言、什么工具,只负责在文件保存的那一刻问一句:"当前这个文档有没有注册格式化提供者?有的话叫它来干活。"开关打开但没人应答,文件就会原样保存,什么也不会发生——这也是"我明明开了 formatOnSave 却没用"最常见的原因。
第二层是格式化提供者,也就是扩展。Black 的扩展、Ruff 的扩展,装完之后会向编辑器注册自己,声明"我能格式化 Python 文档"。它们的 ID 必须被editor.defaultFormatter精确指向,写错一个字符都不行。同一语言可以同时有多个提供者注册,编辑器默认会让你选,选完记在设置里;如果没选过、也没在设置里指定,保存时就会弹一个选择框或者直接跳过。
第三层是语言服务器,比如 Pylance。它本身不负责格式化,但它提供诊断和 code action,包括"整理 import"这类操作。这一层和格式化是并行关系,不是替代关系——你把 Pylance 关了,格式化照样能跑,但你会失去类型提示和一部分自动修复。
搞清这三层之后,排查思路就顺了:保存不动,先怀疑第一层开关和作用域;弹框让你选工具,说明第二层没指定默认;能格式化但 import 没排,说明第三层或对应的 code action 没开。
2.2 保存那一刻的执行顺序
这个顺序我建议你记牢,因为它决定了很多"为什么格式化完之后文件又变脏了"的怪现象。
保存被触发时,编辑器先执行editor.codeActionsOnSave里登记的 code action,再执行格式化。也就是说,修复在前,排版在后。这个顺序是有道理的:lint 修复可能会删掉无用 import、改写语法结构、调整代码行数,如果先格式化再修复,修复产生的新代码就没被排过版,文件保存下来还是"脏"的。反过来先修复再格式化,最终落盘的一定是排好版的形态。
理解了这一点,很多配置就有了依据。Ruff 的source.fixAll.ruff和source.organizeImports.ruff应该放在codeActionsOnSave里,而editor.defaultFormatter指向 Ruff,两者配合才是一次保存完成全套。如果你把 import 排序交给格式化那一侧,那是不对的——格式化器根本不改 import 顺序,它只处理空白。
还有个细节:codeActionsOnSave的值在新版编辑器里推荐用"explicit"。它和老的true的区别在于,"explicit"只在用户主动保存(Ctrl+S)时触发,自动保存(Auto Save)不会去跑 code action。自动保存频繁触发全量 fixAll 会明显拖慢编辑手感,尤其是文件大的时候。老配置里的true相当于"always",想省事也别用true,直接用字符串字面量更稳。
2.3 设置优先级:User、Workspace、语言作用域
VSCode 的设置是分层覆盖的,从宽到窄依次是:用户设置(全局)、工作区设置(.vscode/settings.json)、语言作用域设置("[python]": {...})。窄的覆盖宽的,语言作用域最优先。
这个机制的好处是,你可以在全局开editor.formatOnSave: true图省事,然后在某个不想要格式化的仓库里用工作区设置把它关掉。坏处是排查的时候很容易看漏一层——你以为自己配了,其实是被更窄的一层覆盖掉了,而更窄的那层藏在某个你没注意的文件里。
我的习惯是:格式化相关的设置一律写在工作区,也就是项目根目录的.vscode/settings.json,跟着仓库走。理由很简单,格式化是项目规范的一部分,跟代码一起进版本库才合理,换个机器、换个人拉下来行为一致。全局设置里我只留一两个纯个人偏好的东西,绝不碰defaultFormatter。
工作区设置还有个附带好处:新人克隆仓库,装上推荐扩展,打开就能用,不用再问"你怎么配的"。配套的extensions.json里写推荐扩展 ID,编辑器会主动提示安装,这一步能省掉大量沟通。
3. 从 Black 起步:一份能直接抄的完整配置
3.1 装扩展的正确姿势
Black 在扩展市场里有两种情况要分清:老版本的 Python 扩展内部集成了 Black 作为可选项,新版本已经把它拆成独立的格式化扩展。现在正确的做法是装那个名字里带 formatter 的独立扩展,而不是去旧版 Python 扩展的设置里翻python.formatting.provider。
如果你的settings.json里还留着"python.formatting.provider": "black"这一行,建议直接删掉。它是旧机制的遗留物,新版编辑器根本不会读它,留着只会让下一个人误以为已经配好了。这个坑我见过太多次:有人照着三年前的教程配了一遍,配置看起来满满当当,实际一行都没生效。
顺便提一下 Pylance:它和 Black 不冲突,可以同时装。但要注意 Pylance 自带一部分诊断,如果后面你又开了 Ruff 的 lint,同一个问题会在面板里出现两条,看着很烦。解决方式在 7.3 里细说。
3.2 settings.json 里该写哪几行
下面这份是工作区里的.vscode/settings.json,可以直接抄:
{ "[python]": { "editor.defaultFormatter": "ms-python.black-formatter", "editor.formatOnSave": true, "editor.formatOnSaveMode": "file" }, "black-formatter.importStrategy": "fromEnvironment", "black-formatter.args": [], "files.eol": "\n", "files.insertFinalNewline": true, "files.trimTrailingWhitespace": true }逐行解释一下为什么这么写。
editor.defaultFormatter放在"[python]"作用域里而不是全局,是为了不误伤 JSON、Markdown 这些别的语言。如果把它写在全局,你编辑settings.json本身的时候也可能被拉去格式化,虽然不会出错,但没必要。
editor.formatOnSaveMode显式写成"file"是个防御性动作。这个参数的可选值有file、modifications、modificationsIfAvailable,后两个只格式化"你改过的部分"。有些团队为了减少 diff 噪音会设成modifications,代价是老文件里的历史代码永远不会被整理,慢慢就形成新旧两套风格混在一个文件里的奇观。新仓库没理由用这个模式,显式声明一下省得被别处的配置带偏。
importStrategy设成fromEnvironment的意思是:用当前选中的 Python 解释器环境里安装的那个 Black,而不是扩展自带的版本。这一条极其重要。扩展自带的版本是扩展更新时锁定的,跟你requirements-dev.txt里写的版本大概率对不上,本地跑出来的结果和 CI 跑出来的结果就会有细微差异——最典型的是某个版本改了 magic trailing comma 的处理方式,你的本地 diff 和 CI 报错永远对不上号。
black-formatter.args留空数组是有意的。原因在 4.5 节展开:这里的参数会以命令行形式传给 Black,优先级高于pyproject.toml,两边都写就会出现"改了 pyproject 没用"的情况。
后面三个files.*是配套项。files.eol统一成 LF,insertFinalNewline保证文件末尾有换行,trimTrailingWhitespace清掉行尾空格。这三条严格说不属于格式化工具管辖,但它们处理的是同一类问题,混在一起不做的话,格式化器每次保存都在跟它们打架。
3.3 pyproject.toml 的 Black 段落
配置文件里这一段的写法:
[tool.black] line-length = 100 target-version = ["py311"] include = '\.pyi?$' extend-exclude = ''' /( \.git | \.venv | \.mypy_cache | build | dist )/ '''line-length这里我写 100。Black 默认是 88,这个数字在 2018 年定下来的时候是基于"能在小屏幕上并排看两个文件"的考量。现在显示器普遍宽了,很多团队会调到 100 或 120。选哪个都行,关键是全仓库统一,并且和 lint 的行宽告警阈值对齐。
target-version建议显式写上,别让 Black 去猜。它会影响一部分语法的处理方式,比如某些新语法在旧版本目标下会被加上不同的括号。这个值应该跟你pyproject.toml里requires-python或者 CI 的最低测试版本保持一致。
extend-exclude用了正则的多行写法,把虚拟环境目录、构建产物都排除掉。这里有个经验:不要用--force-exclude之外的任何方式依赖编辑器层面的排除。我用过.vscode/settings.json里的files.exclude来藏虚拟环境,结果那只影响资源管理器显示,Black 该扫还是扫,一个大仓库里扫进.venv就是灾难性的慢。真正的排除要写在工具自己的配置里。
3.4 怎么验证它真的生效了
配完不要直接开工,花两分钟验证一下,能省掉后面一堆怀疑人生。
第一步,随便打开一个.py文件,故意把缩进弄乱、加几个多余空格,保存。看文件有没有被自动整理。没有反应的话,右下角状态栏能看到当前选中的 Python 解释器,点进去确认一下选的是项目虚拟环境,不是系统 Python。
第二步,打开命令面板,执行"格式化文档",看有没有弹出选择格式化器的提示。如果弹了,说明defaultFormatter没被识别,八成是扩展 ID 写错或者扩展没装。
第三步,也是最能说明问题的一步:随便写一行超长的代码,长度在 88 到 100 之间,保存。如果它被断行了,说明生效的是 88(默认值或者扩展自带配置);如果没断,说明你配的 100 生效了。这一招能立刻分辨出"配置到底有没有被读到",比看设置界面靠谱得多。
第四步,验证版本对齐。在终端里跑一下环境内的 Black,看版本号,再对比 CI 配置文件里锁的版本。两个数不一致就先统一,别急着往下走。
4. 迁移到 Ruff:把行为差异压到最小
4.1 迁移前先做行为对照
直接切换是最容易出事的方式。稳妥的做法是先做一次"只读对照":在干净的 worktree 里,用当前锁定版本的 Black 跑一次全量格式化,提交;然后换 Ruff format 再跑一次,git diff --stat看看有多少文件会被改动、改动量多大。
我的经验值是这样:绝大多数仓库的差异文件占比在个位数百分比,改动内容集中在注释对齐、长表达式换行位置、以及空行数量。如果 diff 规模远超这个量级,先别急着迁,检查一下是不是line-length两边配得不一样——这是最常见的假差异来源。
对照做完,心里就有数了。接下来分两步走:先让 Ruff 只接管 lint 和 import 排序,格式化还留给 Black,跑一两周稳定;再让 Ruff format 接管格式化。分步走的好处是,出问题时能立刻定位是哪一环引入的。
4.2 扩展与 settings.json 配置
装 Ruff 的官方扩展。市场里同名的不少,认准扩展详情页里那个 ID,settings.json里写错一个字母就完全没反应,而且不一定报错。
{ "[python]": { "editor.defaultFormatter": "charliermarsh.ruff", "editor.formatOnSave": true, "editor.formatOnSaveMode": "file", "editor.codeActionsOnSave": { "source.fixAll.ruff": "explicit", "source.organizeImports.ruff": "explicit" } }, "ruff.importStrategy": "fromEnvironment", "files.eol": "\n", "files.insertFinalNewline": true, "files.trimTrailingWhitespace": true }和 Black 那份的差别只有三处,但每一处都有讲究。
codeActionsOnSave里用了带后缀的 action kind,source.fixAll.ruff而不是笼统的source.fixAll。带后缀的写法把执行权精确限定给 Ruff,避免其他扩展也注册了同名 action 时排队执行、互相覆盖。多语言仓库里这一点尤其重要,全局的source.fixAll可能同时触发好几种语言的修复器。
两个 action 都设成"explicit",理由前面说过,自动保存不跑修复。如果你的工作流依赖频繁自动保存,这个设置能明显降低输入延迟。
organizeImports和fixAll同时开会不会重复干活?实际不会。Ruff 内部有去重,import 排序的结果和 fixAll 里的 I 规则输出一致。但如果你只想要 import 排序、不想要其他自动修复,那就只留source.organizeImports.ruff这一条,把fixAll去掉——刚接手老仓库的时候我经常这么干,避免一次保存改出几百行 diff 把人吓到。
4.3 pyproject.toml:lint 与 format 两套段落
[tool.ruff] line-length = 100 target-version = "py311" extend-exclude = [".venv", "build", "dist"] [tool.ruff.lint] select = ["E", "F", "I", "UP", "B", "SIM"] ignore = ["E501"] [tool.ruff.lint.isort] known-first-party = ["myapp"] force-sort-within-sections = true [tool.ruff.format] quote-style = "double" indent-style = "space" line-ending = "lf" docstring-code-format = true这里有两个版本相关的坑必须先说。
第一,[tool.ruff.lint]这个嵌套结构是较新版本才有的。早期版本里select、ignore是直接写在[tool.ruff]顶层的。如果你装了老版本的 Ruff,把配置写成新结构它会报"未知字段"或者干脆忽略;反过来,新版 Ruff 读老结构会给出弃用警告。判断方法很简单,先跑一次ruff check .,看输出里的版本号,再对照文档确认结构。
第二,ruff format这个子命令不是一开始就有的。早期版本只有 lint。如果你在终端里敲ruff format .提示命令不存在,那不是配置问题,是版本太老,升级就行。这个坑很隐蔽,因为扩展装上之后defaultFormatter指向它不会报错,只是保存时静默什么都不做。
配置内容逐条说。
select里我列了六组:E是 pycodestyle 的错误类,F是 pyflakes 那套未使用变量、未定义名称的检查,I是 isort 规则,UP是 pyupgrade 那类老写法升级,B是常见陷阱检测(可变默认参数、循环里用闭包之类),SIM是简化建议。这六组覆盖了绝大多数日常问题,误报率也低。再往上加ANN(类型标注)、D(docstring)、S(安全)这些就属于团队偏好了,新项目建议从这六组起步,跑顺了再逐个加。
ignore = ["E501"]是必须的。E501 是行太长告警,但格式化器已经把代码行拆到line-length以内了,唯一还能超长的是长字符串和长注释——这两样格式化器管不了,也不会去动。留着 E501 只会让你的 CI 天天红,而且红得没有意义。要么忽略它,要么把line-length调到一个连注释都能装下的值。我选忽略。
known-first-party要填你自己的顶层包名,这样 import 分组才能正确分出"标准库 / 第三方 / 本项目"三块。不填的话,你自己的包会被当成第三方,跟 requests 排在一起,看着就难受。
quote-style = "double"是 Black 的行为,保持默认就行。如果老仓库里全是单引号、想保持现状,改成"preserve",它不会去统一引号风格——这个值对应 Black 的skip-string-normalization。
line-ending = "lf"在 Windows 上要小心。如果仓库里已有文件的换行是 CRLF,这个设置会让它们全部被重写,产生一个巨大的 diff。配这个之前先确认.gitattributes里的处理策略,具体在 6.3 说。
docstring-code-format = true会把 docstring 里用>>>或三个反引号包起来的代码示例也格式化一遍。这个功能 Black 没有,属于 Ruff 的加分项,用了一段时间之后文档里的示例代码再也不会跟实际代码风格脱节。但它也可能改到你没预期的地方,如果 docstring 里的示例是故意保留某种排版的,用# fmt: off或者干脆关掉这个开关。
4.4 import 排序从 isort 迁到 I 规则
isort 的配置文件通常是setup.cfg或.isort.cfg,里面known_first_party、force_sort_within_sections、multi_line_output这些参数,在 Ruff 里绝大部分都有对应:
| isort 参数 | Ruff 对应 | 说明 |
|---|---|---|
known_first_party | [tool.ruff.lint.isort] known-first-party | 值从字符串改为数组 |
force_sort_within_sections | force-sort-within-sections | 同名,布尔值 |
force_alphabetical_sort_within_sections | force-sort-within-sections | 语义接近,不完全等价 |
multi_line_output | 无需配置 | Ruff 用自己的固定布局 |
include_trailing_comma | 无需配置 | 由格式化器决定 |
line_length | [tool.ruff] line-length | 全局统一 |
multi_line_output和include_trailing_comma没有对应项,因为它们决定了括起来的 import 该怎么换行、要不要尾逗号——这属于格式化范畴,交给ruff format统一处理。迁移时这两个参数直接删掉,别试图保留,保留不了。
迁移的具体操作:先把select里加上I,跑一次ruff check --select I --fix .,看 diff。这个 diff 会包含所有 import 顺序的调整,通常不大,但会涉及很多文件。确认没问题后提交,再把 isort 从 pre-commit 和 CI 里摘掉,两套工具同时管 import 是纯粹的混乱来源。
有个细节:如果项目里用了from __future__ import annotations,isort 的某些配置会把它单独拎出来放最前面。Ruff 的 I 规则默认就处理这个,不需要额外配置。
4.5 双份配置谁覆盖谁:line-length 的坑
这一节值得单独拎出来,因为它让无数人对着配置文件怀疑人生。
VSCode 扩展的配置项(ruff.lineLength、black-formatter.args里的--line-length)在真正执行时,是以命令行参数的形式传给工具的。而命令行参数的优先级高于配置文件。所以只要你两边都写了,结果一定是settings.json赢,pyproject.toml里改什么都没用。
我见过最典型的翻车现场:有人在pyproject.toml里把行宽从 88 调到 100,本地保存还是按 88 断行,以为工具坏了,折腾半天。实际上他扩展设置里早就写死了一个--line-length 88。
我的建议是只留一处:行宽、目标版本这类跟代码本身相关的参数,全部写在pyproject.toml里,settings.json里一个字都不写。这样本地通过扩展读到的是项目配置,CI 上直接读同一个文件,天然一致,不存在"本地过了 CI 挂了"的经典问题。
那什么时候才需要在settings.json里写?只有一种情况:项目根目录不是 Python 项目的根。比如你打开的是一个 monorepo 的顶层目录,pyproject.toml在services/api/下面,扩展可能找不到它。这时候可以用ruff.configuration之类的设置显式指向配置文件路径,而不是去覆盖具体参数。指向配置文件,永远比复制粘贴参数可靠。
5. 本地和 CI 说同一句话
5.1 pre-commit 钩子的顺序编排
本地靠编辑器保证提交前代码已经格式化,但这个保证不可靠——有人用别的编辑器,有人手动git commit -a跳过保存钩子,有人在命令行里直接 merge 进来一批没格式化的代码。所以还需要一道提交时的关卡。
pre-commit 的配置大致长这样:
repos: - repo: https://github.com/<组织>/ruff-pre-commit rev: v0.x.y hooks: - id: ruff args: [--fix] - id: ruff-formatrev那一行填你锁定的版本,它必须和本地开发环境里装的 Ruff 版本一致。不一致就是灾难:CI 用新版、本地用旧版,格式化结果有差异,代码在本地永远"看起来是好的",一到 CI 就报需要重新格式化。我给团队定的规矩是,版本号在pyproject.toml的依赖里锁一次、在 pre-commit 配置里锁一次,两处改必须同时改,评审的时候专门看这一条。
钩子的顺序有讲究:ruff(带--fix)放在ruff-format前面。理由是 lint 修复可能会删代码、改结构,改完之后需要重新排版;反过来做的话,排版完之后 lint 又改了东西,最终落盘的代码不是排过版的。这和编辑器里"修复在前格式化在后"是同一个逻辑,只是把编辑器的顺序在提交阶段重演一遍。
一个实用的细节:pre-commit 第一次跑会卡很久,因为它要把钩子的运行环境装一遍。别以为是自己仓库有问题,等它跑完第二次就快了。另外它对每个文件单独调用一次工具,文件多的时候明显比直接跑ruff format .慢,这是架构决定的,能忍就忍,忍不了就在本地习惯性先手动跑一遍全量。
5.2 CI 里跑 check 而不是 format
CI 上的命令必须是只检查、不修改:
ruff check . ruff format --check .--check的语义是"如果有文件需要修改,退出码非零",它不会动文件。CI 环境里跑格式化并提交是绝对错误的做法——你以为在帮忙,实际制造了一堆没人 review 过的代码变更,还把构建产物的来源搞得不清楚。
Black 那边对应的是black --check --diff .。--diff会把差异直接打在日志里,比只报一句失败有用得多,新人看一眼就知道该改哪。
CI 的报错信息有一个大坑:ruff format --check报"would reformat 3 files",但不告诉你是哪三个文件——不是它不想说,而是默认输出级别不够。加--diff或者调高 verbosity 才能看到具体文件。团队里新人第一次遇到这个几乎都会懵,所以在CONTRIBUTING里写清楚"本地先跑一遍ruff format .再推",能省掉大量来回。
还有一个容易被忽略的点:CI 必须用和本地一样的 Python 版本和一样的工具版本。用pip install ruff而不锁版本,今天装的 0.x 和明天装的可能行为就变了。锁定版本这件事,说多少遍都不算多。
5.3 一次性的全量格式化提交怎么不毁 blame
从 Black 迁到 Ruff format,或者第一次给一个跑了几年的老仓库引入格式化,必然会有一个巨大的全量提交。这个提交之后,git blame会显示每一行都是你改的,同事查历史的时候会想骂人。
解决办法是.git-blame-ignore-revs。把那次全量格式化的 commit hash 写进这个文件,提交到仓库根目录,然后让 git 忽略它:
git config blame.ignoreRevsFile .git-blame-ignore-revs这个配置可以写进仓库的文档让人手动执行,也可以在新版 git 里通过仓库级别配置自动生效。效果是git blame会跳过那些纯粹是格式化的提交,继续往上追溯到真正改逻辑的那一次。
配套要做的是:那个全量格式化提交,消息里一定要写清楚"仅格式化,无逻辑变更",并且单独一个提交,不要和任何功能改动混在一起。混在一起的提交既没法 review,也没法安全回滚。我一般会在提交前先git diff --stat看一眼文件数量,再随机抽查几个文件确认 diff 里只有空白变化,用git diff -w看一遍空输出就能确认。
6. 那些看起来无关紧要却天天咬人的参数
6.1 行宽口径统一
行宽看起来是个小问题,实际是格式化和 lint 冲突的头号来源。代码里涉及行宽的地方有四处:格式化器的line-length、lint 的行长告警阈值、编辑器的那条竖线(editor.rulers)、以及你脑子里"多少算太长"的直觉。
这四处不一致,就会形成一种很别扭的状态:编辑器里看起来没超线,保存一格式化断了行,lint 又没报错,但你写的时候完全不知道边界在哪。我的做法是把前三处对齐成同一个数字:line-length = 100、关掉 E501(因为注释和长字符串注定超)、editor.rulers: [100]。这样写代码的时候有一个明确的视觉边界,越线了就知道该拆。
至于这个数字选多少,我的经验是:纯后端服务代码 100 比较舒服,因为函数调用链和类型标注比较长;数据处理类的代码 88 也行,因为表达式本身不长;写前端或者要用到大量链式调用的场景,有人用 120。真正重要的不是数字本身,而是全仓库只有一个数。
6.2 引号、尾逗号与 magic trailing comma
引号统一这件事,Black 和 Ruff 的默认行为都是把单引号改成双引号,除非字符串里本身包含双引号。这个默认值当年争议很大,现在基本接受了。老仓库如果想保持原样,Ruff 里配quote-style = "preserve",Black 里配skip-string-normalization = true。
更值得说的是 magic trailing comma。规则是:如果你在函数调用或集合字面量的最后一个元素后面留了逗号,格式化器就会把它拆成每行一个;如果没有逗号,只要能塞进一行就塞进一行。
这个行为的实际影响很大。举个最常见的场景:
result = call_function(argument_one, argument_two, argument_three,)末尾那个逗号会让它变成:
result = call_function( argument_one, argument_two, argument_three, )反过来,你把逗号删掉,它又会压回一行。所以"这段代码会不会被拆行"的控制权其实在你手里,不在格式化器手里。很多人抱怨格式化器"总是把代码拆得太散",其实是因为编辑器在他们敲参数时自动补了尾逗号。
这个特性在两边的实现细节上有过细微差异,某些版本对"参数里带注释时是否还拆"的处理不完全一样。跨版本迁移时如果发现这一类 diff,优先怀疑版本差异,而不是配置写错了。
6.3 换行符与文件末尾
换行符这事在跨平台团队里是固定节目。Windows 上默认 CRLF,Linux 和容器里是 LF。格式化器配了line-ending = "lf"就会强制转成 LF,如果 Git 那边又开了自动转换,就会形成"保存时转 LF、检出时转 CRLF、每次保存都算一次修改"的死循环,git 状态永远是脏的。
根治的办法是在仓库根目录放一个.gitattributes:
* text=auto eol=lf *.bat text eol=crlf *.ps1 text eol=crlf把所有文本文件的检出和提交都统一成 LF,只对确实需要 CRLF 的脚本例外。配上git config core.autocrlf false(Linux/macOS 默认就是 false,Windows 上要手动设),这个循环就断掉了。
文件末尾的换行是另一件小事。POSIX 标准里文本文件应该以换行结尾,但很多编辑器不强制。格式化器通常会补上,但如果你同时开了files.insertFinalNewline: true和格式化,两边都会加,不会冲突,只是会让人搞不清是谁加的。行尾多余空格同理,files.trimTrailingWhitespace: true会处理掉,格式化器也会处理掉,两个一起开着没坏处。
6.4 Notebook 与一次性脚本
Jupyter Notebook 的格式化走的是另一套开关。editor.formatOnSave对.ipynb不生效,得用 notebook 专属的配置:
{ "notebook.formatOnSave.enabled": true, "notebook.codeActionsOnSave": { "notebook.source.fixAll.ruff": "explicit", "notebook.source.organizeImports.ruff": "explicit" } }注意这两个键名前面有notebook.前缀,写在"[python]"作用域里是没用的。这一条我踩过,当时纳闷了很久为什么 py 文件格式化正常、ipynb 死活不动。
Notebook 的格式化还有个现实问题:它会把每个 cell 当成独立的代码块处理,跨 cell 的 import 排序结果可能和你预期不一样。做数据分析的时候,我一般只开格式化、关掉 fixAll,避免一个 cell 里的临时变量被判定为"未使用"然后被删掉——虽然后者通常是 lint 规则而不是格式化器干的,但在 notebook 环境里两者的边界确实容易混。
至于那种随手写的临时脚本(test.py、debug.py),我建议单独用files.exclude或者.gitignore把 scratch 目录排除掉,别让它们进格式化范围。这些文件的价值在于快速验证一个想法,加了格式化反而拖慢节奏。
7. 现场排查:保存没反应与结果反复横跳
7.1 保存完全没反应
这是最高频的问题,排查路径我按命中率排了序:
| 检查项 | 具体做法 | 命中率 |
|---|---|---|
| 扩展没装或没启用 | 扩展面板搜一下,看是否已安装、是否在工作区被禁用 | 高 |
defaultFormatterID 写错 | 对照扩展详情页的完整 ID,逐字符核对 | 高 |
| 设置被更窄作用域覆盖 | 打开设置界面搜这个键,看是否显示"已在工作区修改" | 中 |
| 文件语言模式不对 | 右下角看是不是 Python,新文件未保存时可能是 Plain Text | 中 |
| 新版旧版配置混用 | 搜python.formatting.provider之类废弃项并删除 | 中 |
| 格式化超时 | 看输出面板里的日志,大文件可能超过默认超时 | 低 |
| 文件只读或超大 | 检查文件大小,编辑器对超大文件有格式化限制 | 低 |
超时这一条值得单独讲。编辑器有个默认的格式化超时(大约 750 毫秒),超过就取消并提示。几百行的小文件不会触发,但上千行的老代码用 Black 格式化确实可能超。表现是"偶尔能动、偶尔不动",很迷惑。解决办法要么换成更快的工具,要么把超时值调大,要么接受这个现实、用终端手动跑。
7.2 格式化跑两次或结果回退
"结果反复横跳"的症状是:保存一次,代码被格式化了;再保存一次,又变回去了。原因通常是两个格式化器同时生效。
最常见的情形是装了 Black 扩展又装了 Ruff 扩展,两边都在formatOnSave的时候抢着干活。谁最后执行取决于注册顺序,所以表现会很随机。解法是只留一个defaultFormatter,另一个扩展可以留着但不设成默认,或者干脆禁用。
第二种情形是codeActionsOnSave里的 fixAll 和格式化器互相打架。比如某个 lint 规则建议把"a" "b"合并成一个字符串,格式化器又觉得该拆行,两个轮流改,永远收敛不了。这种时候要么把那个规则关掉,要么把 fixAll 从保存钩子里摘出来,改成手动触发。
第三种情形比较少人知道:formatOnSave和formatOnType同时开。Python 的格式化器基本不支持formatOnType(那是给格式化型编辑器用的),但如果哪个配置模板里给你开了,可能会出现"敲一个字符整段重排"的诡异体验。检查一下editor.formatOnType是不是 true,改回 false。
7.3 多语言混排仓库的冲突
仓库里同时有 Python、TypeScript、Markdown 的时候,全局的source.fixAll会触发所有注册了该 action 的扩展。表现是保存一个.py文件,结果 Markdown 的格式化器也跑了;或者保存.ts文件时 Ruff 莫名其妙地试图干活。
解法有两个层次。第一层,把 action kind 带上后缀,source.fixAll.ruff只触发 Ruff,不触发别人。第二层,语言作用域分清楚,"[python]"里只放 Python 相关设置,"[typescript]"、"[markdown]"各管各的。
还有个 Pylance 和 Ruff 诊断重复的问题。两个都开的时候,未定义变量这类问题会在问题面板里出现两条一模一样的。如果觉得吵,可以在设置里降低 Pylance 的诊断级别:
{ "python.analysis.diagnosticSeverityOverrides": { "reportUndefinedVariable": "none", "reportUnusedImport": "none" } }但我不建议一上来就关。Pylance 的类型检查是另一个维度的能力,Ruff 补不了。更合理的做法是让两边各管各的:Pylance 管类型相关的告警,Ruff 管风格和常见错误,重叠的那几个再单独关掉。
7.4 容器与远程环境下的路径问题
用开发容器或者远程开发的时候,fromEnvironment这个策略会遇到一个现实问题:编辑器跑在宿主机上,工具装在容器里,两边路径不通。表现是扩展找不到 Ruff 或者 Black,然后悄悄回退到自带版本。
判断方法是看扩展的输出日志,里面会打印它实际调用的是哪个可执行文件、什么版本。版本号和你环境里pip show出来的不一致,就是回退了。
解决路径有几条:一是在远程场景下把importStrategy明确设成"useBundled",接受用扩展自带版本,但必须在 CI 里用同一个大版本号;二是确保编辑器选中的解释器就是容器里的那个虚拟环境,扩展会顺着解释器路径去找工具;三是干脆放弃扩展的自动查找,用设置显式指向可执行文件的绝对路径——但这会让配置不可移植,只在个人环境里用用还行,别提交到仓库。
我一般选第二条,因为它对团队最友好:所有人都在容器里开发,解释器路径固定,扩展自动找得到,版本靠requirements-dev.txt锁死,本地和 CI 天然一致。
8. 团队落地时我踩过的几个坑
最后说几个不成体系但真实存在的经验。
别一次性给团队推全套规则。Ruff 的规则集可以开得很凶,但你要考虑到同事手上还压着别的活。我的做法是先只开E、F、I三组,跑两周,让大家习惯保存时会改代码这件事,再逐步加UP、B。每次加规则的那个 PR 单独提,diff 里的改动解释清楚,别混在功能提交里。
给"格式化"和"改逻辑"设一条硬边界。任何提交里如果既有格式化改动又有逻辑改动,评审成本会翻倍。pre-commit 能在提交阶段拦住大部分情况,但拦不住 merge 进来的分支。我的经验是每周固定跑一次全量ruff format .,如果有 diff 就单独提交,别攒着。
新人的第一个 PR 大概率会因为格式挂 CI。这不怪他们,是流程没铺好。仓库根目录的.vscode/settings.json加上extensions.json的推荐扩展列表,能让新人克隆下来就是配好的状态。这一步的投入产出比高得离谱,几乎是配一次管一年。
别把# fmt: off当万能药。有些人遇到格式化器把表格类数据排乱了,就在整个文件头部加# fmt: off,然后这个文件就永久脱离格式化范围了。正确的粒度是包住那一段代码,# fmt: off和# fmt: on之间只放确实需要保留排版的部分,比如对齐的常量表、手写的矩阵。
最后一个技巧:如果你想快速确认某段代码到底是被哪个规则或格式化行为改的,在终端跑ruff format --diff 文件名和ruff check --diff 文件名,两条命令的输出能直接告诉你改动来源。比在编辑器里反复撤销重做强太多,尤其在排查"到底是 lint 还是 format 动的"这种问题时,一眼就能分清。