“代码能跑”和“代码在下一个修改者手里不挨骂”,往往隔着十万八千里。我第一次意识到这件事,是有一次捡起一份半年没人动过的遗留代码,自信满满地在本地跑了一遍 Pylint,结果大半个终端都是 C 类和 W 类告警。当时我差点把 Pylint 从项目里删掉,毕竟在很多人眼里,工具只会告诉你代码不够好,而代码不够好这件事,我们又不能靠写文档解决。可冷静两天后我又装了回来:我真正受不了的,不是它能挑出问题,而是我从来不知道这些问题有多碎、多密集、多容易在改动之后悄悄送回主干。
这篇文章说的就是 Pylint 和 Flake8 这对组合,怎么在 Python 项目里充当持续不断的“代码质量雷达”。它适合这些读者:想给团队加一道最低门槛、又怕配置太复杂做不下去的人;经历过 Code Review 时因为“这行太长”“这个 import 没用到”“这个分支真的不复杂吗”来回拉扯的人;也适合已经准备引入静态检查工具,但听到“还得写 .pylintrc”就打了退堂鼓的个人开发者。我会把实际运行一遍之后看到的警告、没有看文档踩过的坑、以及最终沉淀到 CI 里的那套配置,都摊开来说清楚。
1. 先面对现实:Pylint 和 Flake8 到底在“骂”什么
1.1 静态检查查的不是 bug,是坏味道
静态检查工具的定位,很难用一句话讲清楚。它不像测试那样直接验证一条输入路径是否正确,也不像类型标注一样约束实参和返回值的形状,它做的是两件事:第一,找出代码里明显“没收拾干净”的痕迹;第二,在一个很久没人说话的代码库里,用机器可执行的方式提醒你,这里将来可能会让某个人拍桌子。
Pylint 的报错分成几类,代码里经常看到 E、W、C、R、F 这些开头。E 是 error,这类问题通常是运行时会出事的,比如引用了不存在的变量、错误地调用了不存在的方法;W 是 warning,还没到立刻报警的程度,但往往带着隐患,比如被覆盖的表达式;C 是 convention,主要跟编码习惯有关,缺 docstring、命名不符合 PEP8、行首多了空格;R 是 refactor,意思是这段代码能跑,但“这样写会让后来的人想重写”。对一个新接入的项目来说,R 和 C 通常占大头,它们不会让你上线崩,但会让重构的人无从下手。
Flake8 则是一个更收敛的组合包,它把 pycodestyle、pyflakes 和 mccabe 三份检查工具绑在一起,既能检查出像“第 88 行第 21 个字符后面多了个空格”这种格式问题,也能检查出“这个变量赋值了但一次都没用”“这个 import 进来之后没被引用过”。它的检查维度看起来没有 Pylint 丰富,但胜在轻量、直接、好解释。
1.2 为什么不是只装其中一个
这个问题我一度也纠结了很久。只装 Pylint,它能覆盖 Flake8 里 pyflakes 的绝大多数问题吗?能,但 Pylint 输出太嘈杂,一百行代码跑到最后,真正重要的一两个 E 级别错误经常淹没在几十条 C 类风格建议里。只装 Flake8,它的风格检查又只关心表面格式,不会对“这个函数参数太多”“这朵烂花一样的分支嵌套迟早出事”这样涉及可维护性的问题给反应。
所以推荐把两者放在不同的“检查高度”里用:Flake8 处理编码规约的底线,Pylint 负责再往上走一层的逻辑味道。你可以把 Pylint 想象成一位稍微啰嗦的设计评审,它会提醒“你定义了方法但没 self 调用,是不是忘了注册到路由”,而 Flake8 更像是在门口拦人的保安,先确保每个人不要踩着奇奇怪怪的缩进进来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 初接入时的“视觉冲击”:从一屏告警开始认识工具脾气
2.1 第一次在项目里跑起来的配置门槛
接一个新项目,我不会一上来就写一堆自定义配置,而是先用默认配置跑一遍,让工具展示真实数据。安装方面其实没什么悬念,直接用 pip 装就好:
bash复制pip install pylint flake8
接着对包名或者整个源码目录分别运行:
bash复制pylint mypackage
flake8 mypackage/
如果项目里没有配置文件,命令行输出的结果往往会很“结实”:Pylint 会给你一个得分,比如 3.54/10,下面跟着一条条形如 mypackage/core.py:45:11: R1714: Consider merging these comparisons with 'in' 的消息;Flake8 的格式则是 core.py:45:13: F401 'json' imported but unused。两者都带着文件路径、行列号、消息 ID,这本身就说明它们的设计理念是一个给编辑器用、一个给命令行用,能让你在代码里精准落到出错位置。
2.2 常见的默认告警长什么样
只看说明不看实际,永远体会不到工具为什么惹人烦也讨人喜欢。以下是我在新代码里大概率会看到的三类默认告警:
python复制# 1. F401:json 被 import 进来,后续完全没用
import json
# 2. W0611:urllib 模块 import 之后从来未被使用
import urllib
def fetch_user(user_id):
# 3. R0913:参数超过 5 个,Pylint 认为这里复杂度开始上升
result = {
"user_id": user_id,
"status": 1,
"message": "ok",
"extra": None,
"trace": [],
}
return result
对于 project 里一个一百行的模块,未使用 import 往往出现过很多次。很多人会说“这又不影响运行时”,确实不影响,但它会误导后来的人,让人以为项目已经依赖某个库,从而在清理依赖时反复犹豫。尤其配合 CI 之后,所有 PR 里都有一条真的会让人发疯:“F401”,哪怕代码可以正常合并,也说明当前模块里残留了没有实际生效的 import 痕迹。
2.3 默认配置的另一个“埋伏”:行宽 79
默认的 Flake8 和 Pylint 都会把单行长度限制在 79 个字符左右,这是从老式终端时代沿袭来的默认值。今天如果直接用默认配置跑一个现代代码库,你会看到海量的 E501 和 C0301,而这些告警里面大概有一半其实是无意义的:字符串常量被长 URL 截断、测试断言里写了一个特别长的文案、SQL 模板占了很多行。这种情况下,多数团队会干的第一件事就是把 max-line-length 拉到 100 或者 120。
我建议在自己写配置之前,先跑一遍默认的 Flake8,然后执行下面这条命令:
bash复制flake8 mypackage/ --count --statistics
它会把这个目录下各类错误按频率排出来,让你看见到底是 C0301(行太长)占比 70%,还是 F401(未使用 import)占比 60%。直接看统计,别急着改,这对后面决定如何裁剪规则特别重要。
3. Pylint 和 Flake8 的分工:一把尺子量风格,一把尺子量味道
3.1 Flake8 更像“自律底线的执行器”
我团队里的约定是:Flake8 可以完全交给机器管,因为它的每一条告警基本都建立在“可证明的事实”上:那一行确实超长、这个变量确实没被读取、这个 import 确实未被使用。它对语法树的理解不像 Pylint 那么深入,但它不猜,不靠启发式规则去推断“你可能想在这里加个 else”。这一点在做 CI 时非常重要,因为确定性意味着你可以放心让 Flake8 检查到“零告警”再放行,而不会有几条模棱两可的提示总在挑战人的耐心。
Flake8 的核心实际上来自三部分:
| 检查器 | 负责内容 | 典型问题前缀 |
|---|---|---|
| PyFlakes | 未使用变量、未使用 import、未定义名称 | F |
| pycodestyle | 空格、缩进、行长度、空行数量 | E / W |
| McCabe | 函数圈复杂度 | C90 |
圈复杂度是 Flake8 会检测的一个非常有价值但又容易被人忽略的指标。默认阈值是 10,也就是说一个函数的 if / for / while / except 等分叉路径数超过 10 时,它会报 C901。十次分支确实很多,遇到这种事,应该认真考虑把函数拆开,而不仅仅是调高阈值。相反地,行长度这类规则,则适合通过配置来适配团队标准。
3.2 Pylint 更接近“理性的猜谜者”
Pylint 的规则并不只是表面格式检查,它会对代码做一定程度的符号分析、作用域分析,有的规则会去看你的类里是否存在两个方法之间写得几乎一模一样,比如 R0801(similar lines)和 R0914(too-many-locals);有的会盯住某个方法明明没有用 self,却还是定义成了实例方法,给出 R0201(method could be a function)。这些并不是运行时 bug,而是代码演进时技术债的前兆。
正是这种“猜测性”,让 Pylint 使用起来比 Flake8 复杂一个等级。好的地方是,它能帮你在 Code Review 之前,先把“这个函数参数太多了”“这个模块 import 顺序乱了”“这个异常被 catch 之后什么也没做”这些话提前说出来。坏的地方是,它常常把“不同的合理写法”也当作告警。比如 if x in list_a or x in list_b 这种写法,它可以读成“不如合并改成 if x in list_a + list_b”,实际上因为 list_a 和 list_b 可能是不同含义的集合,被合并之后逻辑完全不对。这时候,你需要理解每条规则的适用场景,而不是机械地执行。
3.3 同一份代码,两个检查器能看出不同东西
下面是一段故意写得不像样但可以运行的代码:
python复制import os
import sys
def get_level(input_value):
if isinstance(input_value, int):
if input_value > 10:
level = "high"
elif input_value > 5:
level = "medium"
else:
level = "low"
elif isinstance(input_value, str):
if input_value.isdigit():
level = get_level(int(input_value))
else:
level = "unknown"
else:
level = "invalid"
return level
Flake8 会告诉你三件事:import os 是 F401,未使用;第 5 行的函数 get_level 开头没有两个空行,这是 E302;第 6 行“10”之后有额外的空格,或者行尾有个多余空白,它都能找到。Pylint 则会说得更多:R0912(too-many-branches)可能被触发,它还会提示 get_level 里没有 docstring(C0116),甚至可能因为同名变量 level 在不同分支里被反复赋值而建议你是否真的需要这么多分支。
注意,这条函数实际逻辑其实并不复杂,但 Pylint 会从“分支数量”这个角度给你压力。它并不那么了解产品经理口中的“一个输入有 int、string、非法值三种可能”,只看到代码路径数量。不能全盘照收,但能帮你在重构时思考:那些难以测试的分支,是不是还能把分支结构抽得更清晰?这是工具的价值所在。
4. 配置踩坑纪实:别让默认规则淹没主线
4.1 我不建议一上来就写一份“空白版”配置
按某些教程的推荐,在 .pylintrc 里把 disable 写上一长串,是让 Pylint 瞬间从 2 分变成 10 分的捷径。但这样做的代价是:规则也被卸载得差不多。比如直接禁用 missing-module-docstring 和 missing-function-docstring,虽然可以减少代码里敲注释的麻烦,但也丢失了这些检查带来的“模块入口有解释”的价值。相比全禁,我更喜欢按模块或者单行豁免。
单行豁免的方法是保留规则,在不需要它的一行代码后面加注释:
python复制# 这个字典结构是故意保持扁平,避免过度抽象
result = {"a": {"b": {"c": 1}}} # pylint: disable=too-many-nested-blocks
Flake8 同理,也有 # noqa。如果你希望忽略同行内的某个规则,更规范的做法是写 # noqa: F401,这样后面维护的人知道你是专门忽略某一条而不是把整行可能的检查都放弃掉:
python复制import json # noqa: F401
4.2 最让我“错怪” Pylint 的一条规则
Pylint 对“方法可以静态化”的判断,是我在早期接入时最想删掉的规则之一。它叫 no-self-use(在旧版 Pylint 中编码是 R0201),含义是:一个方法虽然定义在类里面,但它从头到尾没有使用过 self 里的任何状态,那么理论上可以用 @staticmethod 改成一个普通函数。这个判断在很多业务场景里是“看着合理但不可行”的,因为外部调用方式在接口层面已经定死了,你就是希望在这个方法里保留对调用协议的兼容,哪怕它不用 self。
但是不能因噎废食。如果我直接把 no-self-use 禁掉,自己以后也看不到同类提醒了。好一点的方案是:团队约定在函数注释里写清楚“保留实例方法是为了兼容特定接口”,然后对这一处做局部 disable,这样既保留了对别处的检查能力,又给局部的例外一个文化层面的出口。
4.3 行宽和 docstring 是新手最容易“翻车”的两处
把 max-line-length 调到 120 几乎是所有 Python 项目的共识,但不代表你从此不需要处理长行。真正的问题经常出现在长 URL 字符串上,我建议这类情况用字符串拼接或者把常量抽出来。还有一个常见误会:Flake8 的默认规则是基于 PEP8,Pylint 里有一种缺失模块说明的 C0114/C0115/C0116 告警。对个人小项目来说,每个模块一上来先写三行 docstring 很消耗热情,但如果你在一个需要长期维护的包里,这一个 docstring 恰恰是将来自己定位问题的第一块地标。
我的配置建议如下:模块 docstring 保留,函数 docstring 在公共 API 层保留,测试函数不强制要 docstring。这样既不会让测试代码写五十个说明文字,也能让公共模块的入口保持可读。
5. 把它们接到开发流程里:从“手动跑一次”到“每次提交都跑”
5.1 先做一个本地命令,能帮你消灭大部分摩擦
每次手动去跑两条命令很烦,尤其是换到新环境里,最容易发生“本地明明过了、CI 挂了”的情况。原因多半是本地漏装了一个插件,或者是本地版本和 CI 上的版本不一致。我比较推荐在项目根目录记录一份 requirement 文件,比如 requirements-dev.txt 中明确列出:
text复制pylint==3.0.3
flake8==7.0.0
然后通过一个 make 命令或者 shell 脚本来统一入口:
bash复制#!/usr/bin/env bash
set -euo pipefail
flake8 src tests --max-line-length=120 --max-complexity=12
pylint src --fail-under=8.5
其中 --fail-under 是 Pylint 很重要的一个参数,用于设定最低分数。如果你不想卡死在 10 分,可以把门槛降到一个自己能接受的数值,比如 8.0。这样代码库里仍然会有一些历史告警尚未清理,但只要提交不会把整体得分拉得更低,你就能在渐进改进的同时保证主线不烂。
Flake8 的退出码天然是“非零即失败”,所以在 CI 里你可以直接把它作为检查步骤。Pylint 则记得把得分阈值写进去,否则就算得分只有 5.0,也可能因为默认退出码在一些配置下为 0,让 CI 假装通过。
5.2 pre-commit:把检查放到写代码时而不是提交后
接入阶段,与其先搭一套复杂的 CI,不如先用 pre-commit 把检查往前移。安装并初始化之后,配置文件 .pre-commit-config.yaml 可以写成这样:
yaml复制repos:
- repo: https://github.com/pycqa/flake8
rev: 7.0.0
hooks:
- id: flake8
args: [--max-line-length=120]
- repo: https://github.com/pycqa/pylint
rev: pylint-3.0.3
hooks:
- id: pylint
args: [--fail-under=8.5, src]
运行命令:
bash复制pre-commit install
之后每次 git commit 时,Pro-commit 会先执行这两个工具,如果代码里还有 F401 或者某个没整理干净的空行,提交就会被阻断。这个机制的优点是:它在开发者的机器上工作,速度快、反馈直观,又不必看 CI 的脸色。
5.3 在 CI 里留一个准入门槛
我把 CI 阶段设计成三层:
第一层,跑“格式 + 兼容性”检查,就是用 Flake8 处理风格和未使用变量。第二层,跑 Pylint,对核心包做一个“分数门槛”式的检查;第三层才是跑测试。这样排布的原因是,Flake8 结果非常稳定,一旦历史代码清零,之后的大部分提交不应该再出现风格问题;而 Pylint 的某些提示需要人来判断,它的告警不能作为一个无脑 fail 的条件,更适合在分数层面兜底。
在 GitHub Actions 里的最小写法可能长这样:
yaml复制jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install -r requirements-dev.txt
- run: flake8 src tests --max-line-length=120
- run: pylint src --fail-under=8.5
之后接到每个 PR 上,只要出现规则破坏,红叉就会亮起来,比人工提醒体面得多。
5.4 编辑器里的体验优化
vscode 里配置 Pylint 和 Flake8,我更推荐用 settings.json 明示,而不是只依赖装插件时的图形界面,这样团队中每个人拿到的体验一致。一个比较旧的配置思路是把所有静态检查工具全开,但这样会信息过载。更稳的做法是:默认只开 Pylint 作为代码提示,Flake8 留给 pre-commit 和 CI。
要在 vscode 里单独使用一遍,需要在扩展搜索“Python”,然后:
json复制{
"pylint.enabled": true,
"pylint.args": ["--fail-under=8.0"],
"flake8.enabled": false
}
不推荐在编辑器里把 Pylint 的 disable 规则写到全局 settings.json,因为配置文件一旦分散到个人编辑器里,很难保证团队一致性。应该让 .pylintrc 和 .flake8 待在仓库里,编辑器的 args 只保留路径类参数。
6. 关于“规则误报”和“规则共识”的长期思考
6.1 一个项目最理想的规则状态是“吵得起、改得动”
在推行静态检查这件事上,我见过两级分化:一种团队完全采用默认配置,结果每个 PR 里 90% 的注释都在处理格式问题;另一种团队为了跑通,罗列了一百多条 disable,最终规则形同虚设。真正合适的做法应该是在项目演进过程中,经过两三次团队讨论,把“不符合团队习惯但默认开启的规则”关掉,把“曾经导致线上问题的规则”单独加强。
实际操作中,我通常保留 Pylint 里面这几个重要的规则不随意禁用:
- E0602(undefined-variable):命名的变量是否真的在作用域里。
- E1101(no-member):实例上调用一个不存在的属性,这常常是拼写错误。
- W0612(unused-variable)与 W0613(unused-argument):代码里最有迷惑性的死代码来源。
- R1714(consider-using-in):条件里反复出现
x == y or x == z,可以合并成x in {y, z}。
Flake8 层面则建议至少保留 F401(未使用 import)、F821(未定义名称)和 C901(圈复杂度)这三道线。
6.2 别把工具得分当成“绩效考核”
有一次我用 Pylint 把一个微服务从 4.2 分慢慢拉到 9.8 分,感到很满足。但后来我意识到,这个分数里包含了大量“docstring 补齐”“变量名调整”带来的贡献,而真正的“漏洞、边界条件、异常状态处理”其实还得靠单测和 Code Review。静态检查工具的价值不该被夸大成“代码质量的全部”。
换一个角度看:它们更像是一把最基础的滤网。过滤完细碎杂质后,你在 Code Review 里才有更多精力去讨论接口设计、数据一致性和错误恢复逻辑。特别是接手老项目时,如果历史代码存量非常庞大,我不建议在第一天就全局清零,而是可以先通过 CI 把新增代码纳入检查。使用 Flake8 或 Pylint 时可以在配置里用 per-file-ignores 或者 ignore-paths 避开历史目录:
ini复制[flake8]
max-line-length = 120
per-file-ignores =
scripts/*.py: E402,F403,F405
migrations/*.py: E401,E402
Pylint 可以用类似方案:
ini复制[MASTER]
ignore-patterns = ^.*migration.*\.py$
这样老代码不会被一次告警淹没,新代码却还是在规则范围内。过一个季度再把历史目录逐渐放回检查列表,比一次性压上效果好得多。
6.3 依赖配置漂移的坑
Flake8 和 Pylint 的插件体系很发达,尤其 Flake8 能装一堆扩展,比如 flake8-docstrings、flake8-import-order、flake8-eradicate。一旦团队中有人本地装的插件和 CI 上装的插件不一致,检查结果就会出现“绿勾本地通过、红叉 CI 失败”的戏剧性场面。要把所有依赖锁在 requirements-dev.txt 里,并保证 pip install -r requirements-dev.txt 是唯一安装入口,而不是靠全局环境里的一个“可能更新过”的包。
我曾经遇到过一个真实的坑:同事本地 Flake8 版本是 6.x,CI 里是 5.x,新版 Flake8 会把某些不推荐的 # noqa 写法标记成告警,结果 CI 全红,本地怎么跑都绿。后来加上锁定版本,这个坑就再没出现过。
7. 阶段化推行方案:接入、收敛、维持
7.1 第一阶段:先让数据说话
我建议新项目直接全量开启 Flake8 和 Pylint,多花一个上午处理“原始债务”是值得的。老项目可以先只统计,不 fail,哪怕跑出来几千条告警,也可以先保留日志来量化规模。可以用如下两步走:
bash复制flake8 src --count --statistics > lint_report.txt
pylint src --reports=y > pylint_report.txt
这段时间的工具输出不要直接发给团队,而是当作分析基线。你需要看的是:不安全的代码多不多,未使用的 import 是否占了很大比重,哪些模块被点名最多。
7.2 第二阶段:在提交关口设“只减不增”
基线统计完成之后,把 CI 的 min 分数设置成略低于当前基线,但要保证一条规则:只要新增代码质量更差,得分就下降。这是一种增量改进策略,不需要一次性清空所有旧账,但必须防止“我这次改动给几百行代码引入了几十条 warning”的情况。
理想的 git diff 审查流程里,人脑要解决的只有逻辑变化,机器要负责的是把明显的坏味道拦截住。在这个阶段,少量历史告警如果长时间没有被清掉,会慢慢变得“人人在见怪不怪”——这是比较危险的情绪,它会让团队成员对报告中真正刺眼的 E 级错误失去敏感度。
7.3 第三阶段:把规则调整变成一次“团队受控变更”
有些规则是否保留,不应该由某个人一锤子定音。我的经验是,在推行后的第三周左右,拉上一次例会讨论几个争议点:
- 文档串的要求到哪个层级为止?
- 行长使用 100 还是 120?
- 圈复杂度阈值是 10 还是 12?
- 是否允许在测试代码里为了可读性禁用某些命名规则?
定完后把结论写进 README 里的“代码规范”一栏,其中明确哪个规则对应的工具、哪个规则允许模块级豁免。这样即使后来换人维护,也不会演变成“新来的同事不知道这条规则为什么存在,于是删掉了一个重要检查”。
8. 实际的一次重构案例:Pylint 和 Flake8 怎么帮我发现死代码
为了让你看到这套工具组合的真实效果,我拿一个简化版的服务模块来演示。原本的代码可能是这样的:
python复制import logging
import os
import requests
import redis
def fetch(url, config):
log = logging.getLogger(__name__)
client = redis.from_url(config["redis_url"])
payload = {"url": url, "client": client}
log.info("fetch url %s", url)
response = requests.get(url, timeout=10)
payload["status"] = response.status_code
return payload
Flake8 跑完会直接指出:os 没用到,redis 这个 import 被赋值给 client 后,client 变量实际只在 dict 构造时出现过,后续没有再参与请求或连接复用。有时候我们误以为的“预留用法”其实就是死代码。Pylint 除了同样能说明 import 问题之外,还会提示:函数里局部变量过多,payload 这种字典里塞了请求对象、日志对象、配置对象,其实已经超过了一个函数该承担的管理粒度。
清理之后,我把 redis 的连接初始化放到显式初始化的类属性或依赖注入环节,requests 请求变成独立的调用函数,代码变成更可测的形态。运行两次 lint 都是静默退出,代码逻辑的可读性也好了很多。不是 Pylint 逼着我写了神奇代码,而是它把“你虽然没写错,但用起来会很别扭”的直觉,变成了一个可重复执行的提醒机制。
9. 写在最后的一些顺手的经验
开发时直接按默认方式跑一次,看看各条噪声的分布。网上流传的某些“完全体配置文件”有很多并不是针对你的项目定制的,照搬容易造成告警消失,但又不能真正培养代码成员的规范意识。尤其是新手阶段,我建议亲手把每一条禁用的规则弄明白之后再禁掉,或者在代码里对单个区域用 # pylint: disable=rule-id 做局部豁免,保留日志记录,后续如果发现某个例外被反复使用,再提升到配置文件里统一配置。
Flake8 适合做机器强约束,Pylint 适合结合人工判断做深度评审。两者的输出格式都很直接,内置的规则分组也可以方便地配合代码审查流程。真正体会过几轮“提交前自动挡住小问题”的顺畅,再回头看那些随手留下的未使用 import、超长字符串、缩进混乱的代码块,你会开始习惯性地想:这里是不是可以写得更干净一点。
对我个人来说,Pylint 和 Flake8 不是能一键把烂项目变好的银弹。它们更像一个耐心的提醒者,做危险仰卧起坐时把你从“代码能跑就行”的舒适区里拽出来。先把这两条线收进开发流程,你就会发现,自己把注意力更多地留给了更难的问题,而蠢错误越来越少出现在别人眼前。
