写这篇博文前,我先说说自己的真实感受:如果你在一个 Python 项目里待过三个月以上,多半会对“代码能跑但不敢改”这句话有切身体会。表面上功能都正常,但每当你打算动某个方法时,总得先看半天上下文,生怕一不留神踩到隐藏的坏味道。Pylint 和 Flake8 这两个工具,恰恰就是用来对付这种情况的——一个负责把代码里“不太合适但能运行”的地方挖出来,一个负责快速扫掉低级问题和风格错误。这篇博文,我不打算把官方文档照搬一遍,而是想从一个实际接手的项目出发,聊聊为什么我会把这两个工具当成一套组合拳来用、怎么给它们定规则、在团队协作里怎么落地,以及你会踩到哪些坑。
1. Pylint 和 Flake8 各自解决什么问题:为什么我坚持两个都用
很多刚接触代码质量检查的朋友会问:Pylint 和 Flake8 不是重复了吗?都是检查 Python 代码的,选一个不就行了?
我第一次听到这个说法也觉得有点道理,但真把它们跑在一个中型项目上之后,我才意识到这俩工具的指导思想完全不一样。
1.1 Pylint:更像一位逐行审查的老法师
Pylint 干的事情,远远超过“找出语法错误”。它会去分析你代码里的命名规范、模块结构、函数复杂度、未使用的变量、危险的默认参数、甚至一些可以重构的逻辑。它的输出并不只是“这里有 bug”,还会告诉你“这段代码虽然能运行,但存在哪些潜在的风险和质量问题”。
举个例子,你写了一段类似这样的代码:
python复制def parse_user_data(raw_data):
user_id = raw_data.get("user_id")
if user_id is not None:
return {"id": user_id}
else:
return {}
这段代码本身没有语法错误,运行也没问题。但 Pylint 看到这种 if ... else ... 结构时,可能会提示你:既然 if 分支已经 return 了,后面根本不需要 else,直接写 return {} 就行。这就是所谓的 no-else-return 检查,属于 Pylint 的 R 类(重构建议)消息。
Pylint 的可怕之处不在于它话多,而在于它能把“这里为什么别扭”的原因说出来。它倾向于站在代码审阅者的视角,一层层往下看:命名是否规范、函数是否太长、参数是否太多、是否有多个分支可以合并、有没有不必要的复杂判断。这种检查非常适合在代码合入主线之前做,因为它能逼着开发者提前思考“我这段代码,别人三个月后能不能看懂”。
另一个很实际的功能是 Pylint 会给代码打分。你运行完以后,它会在大方框里显示一个分数,比如 Your code has been rated at 7.53/10。这个分数一开始看很扎心,但正是这种量化的方式,让团队能定出一个最低门槛。比如你在线下开发时随便跑,分数低一点无所谓;但只要提交到 CI,就设置 fail-under=8.0,低于 8 分直接构建失败。这种“分数底线”机制,比单纯靠自觉有用得多。
1.2 Flake8:轻量级组合拳,讲究快速和明确
Flake8 的定位则非常克制。它其实是一个封装工具,内部把三个检查器组合在一起:
- Pyflakes:从语法和语义层面检查实际错误,比如导入了但从未使用、变量未定义、变量赋值后没被使用等等。这些是真正的“低级错误”,但 Python 解释器不一定会在运行前帮你抓出来。
- pycodestyle:检查代码风格,比如行长度是否超过 79/88、缺少空格、缩进不一致、空行数量不对。它关心的不是逻辑,而是“看起来是否符合 PEP 8 的习惯”。
- McCabe:提供圈复杂度(cyclomatic complexity)检查,用来衡量函数的条件分支有多复杂。
Flake8 的核心设计哲学就是“小而快”。它不像 Pylint 那样做深层的类型推断或数据流分析,它主要通过解析 AST 来快速找到明显的问题。它会告诉你每一行哪里有问题,比如 main.py:42:1: F401 'os' imported but unused,整个报错格式非常稳定:文件名、行号、列号、错误代码、描述。
这种设计带来的好处是执行速度非常快。在大型代码库里跑一遍 Pylint 可能要十几秒甚至几十秒,而 Flake8 往往一两秒不到就结束了。所以你完全可以在每次保存文件时、每次 git commit 之前跑它,反馈非常及时。
1.3 一层管逻辑信号,一层管风格和低级错误
那么问题来了,既然 Pylint 也能查命名、也能查未使用变量,为什么还要 Flake8?
关键在于定位不同。Pylint 更像是一位经验丰富的架构师,帮你做深度审阅;Flake8 则像一个敏锐的雷达,专门负责快速发现机械性、确定性的问题。Pylint 的检查过程更抽象,需要分析代码路径,所以它的报错信息有时候是“建议性”的,不能全信,得人工判断;Flake8 的报错则非常“铁板钉钉”,未使用导入就是未使用导入,行太长就是行太长,几乎不存在误判空间。
所以我的做法是两者都上,但分工明确:
- Flake8 跑在更前置的环节,强调快速扫雷。
- Pylint 作为更深入的代码审查关口,在设计评审或合并请求前运行。
这样既能保证低级问题不会漏掉,又能让代码的“长期质量”有人把关。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 把规则先定下来:配置文件的思路和实际操作
很多人在第一次接入 Pylint 或 Flake8 时,犯的最大错误就是直接跑默认配置。默认配置当然没有错,但它不一定适合你手头项目的实际情况。比如 Pylint 默认要求模块、类、函数都必须有 docstring,如果你的项目赶进度、很少写 docstring,那一上来就会满屏告警,反而掩盖了真正的逻辑问题。
所以在把这两个工具推向全团队之前,一定要先花一点时间把配置文件定好。
2.1 Flake8 的配置:三组最容易争论的规则
Flake8 的配置文件可以单独命名为 .flake8,也可以放在 tox.ini、setup.cfg 的某个 section 里。我习惯单独用 .flake8,这样团队一看就知道这个项目的检查规则是怎么定义的。
先看一个比较常见的 .flake8 配置:
ini复制[flake8]
max-line-length = 88
extend-ignore =
E203,
W503
exclude =
.git,
__pycache__,
build,
dist,
docs/conf.py
这里面有三组规则特别容易引起争论,我一个个解释。
首先是 行长度。pycodestyle 的默认上限是 79,这是从早年终端宽度沿用下来的。但今天几乎没人真的在 80 字符终端里写代码了,大多数团队会放宽到 88 或 100。我比较推荐 88,因为这是 Black 格式化工具的默认值。如果你用了 Black,那么所有代码都会自动按 88 换行,Flake8 也用 88 就不会产生冲突。
然后是 E203 和 W503。这两条规则和 Black 的默认风格直接冲突。E203 是“冒号前后不要有空格”,但 Black 在切片时会写成 data[1:4],如果你写了 data[1 : 4],Black 又会把它改回去,所以 E203 经常被误报。W503 是“二元运算符应该出现在行的结尾”,而 Black 的换行风格会把运算符放在下一行开头,所以也经常冲突。最稳妥的做法是在配置文件里把这两条忽略掉。
2.2 Pylint 的配置:不要一刀切关闭所有 docstring 检查
Pylint 的默认配置同样会有很多噪音。最典型的就是 docstring 相关的三条消息:
- C0114:模块缺少 docstring
- C0115:类缺少 docstring
- C0116:函数或方法缺少 docstring
如果你的团队确实不写 docstring,那这三条每条都可能刷屏。我并不是反对 docstring,但对于一个已经写了很久的老项目来说,临时补所有 docstring 显然不现实。所以务实的做法是:把 docstring 相关检查从默认开启列表中关掉,或者从某个时间点开始,只要求新增代码补全 docstring。这通常通过 .pylintrc 文件来实现。
你可以用下面的命令先导出一份完整的默认配置:
bash复制pylint --generate-rcfile > .pylintrc
然后修改 [MESSAGES CONTROL] 段。很多团队会把这一段的 disable 改成一个较长的列表。不过我不太建议做成一个又臭又长的 disable 列表,因为那样会让后人看不懂你到底关了哪些规则。更好的做法是按需分组,并在旁边加注释说明理由。
示例:
ini复制[MESSAGES CONTROL]
disable=
missing-module-docstring,
missing-function-docstring,
missing-class-docstring,
duplicate-code,
too-few-public-methods
这里的 duplicate-code 是 Pylint 里经常误报的一项,它会把两段看起来相似但不一定真的该合并的代码标出来。如果代码库已经很大,开启它很容易产生一堆背景噪音;如果团队打算做去重工作,那再单独打开它,针对性地处理反而更好。
2.3 Pylint 的分数门槛怎么设才有意义
Pylint 的“评分制”有时会让人把它理解成考试分数。但其实它不是从 0 开始加分,而是默认从 10 分开始,每发现一个问题就扣一定的分数。具体扣多少分和代码规模有关,没必要死抠公式,你只需要理解两个关键结论:
第一,不要要求任何一次改动都把分数保持在 10 分。这不现实,因为默认规则里有一些是强约定,比如命名风格、docstring,即便代码逻辑完全正确,也可能因为这些被扣分。
第二,fail-under 是真正的门槛工具。你可以在 .pylintrc 里写:
ini复制[MASTER]
fail-under=8.0
这样 Pylint 运行后,如果对某个库或模块的评价低于 8 分,返回值就不是 0,CI 就会失败。这套机制的妙处在于它允许“旧账慢慢还”:一开始项目可能只有 5 分,你可以先把门槛定为 5,让它通过;然后每两周修一批问题,再把门槛调到 6、7、8。我实际用下来,这种渐进式的提分策略,比某天突然让所有开发者“必须把分提到 9 分以上”有效得多,也不会引发团队集体反感。
3. Pylint 实操:从报错信息里读懂项目真正的味道
前面讲了不少配置,现在真正上手体验一下 Pylint 会输出什么。我随手写个小文件来模拟一种常见场景:
python复制# demo.py
import os
import sys
import datetime
def handle_order(order_id, db_conn):
query = "select * from orders"
if order_id > 0:
rows = db_conn.execute(query)
for index in range(len(rows)):
print(rows[index])
return True
else:
return False
如果直接用默认规则运行 pylint demo.py,你会看到密密麻麻的输出。我把其中几个典型消息拆开讲。
3.1 未使用的导入和不满意的名字
文件开头导入了 os、sys、datetime,但下面的代码完全没用它们。Pylint 会给出一条 W 类(Warning)消息,提示 Unused import os。这种问题 Flake8 也会报,但 Pylint 的写法更侧重“这会增加模块的耦合面”。
另外,文件名 demo.py 被 Pylint 视作一个模块,它可能会提示模块名不符合 snake_case。如果你在一个真正的项目里,把所有文件命名为 demo_v1_final.py 这种风格的文件,Pylint 会毫不客气地列出命名问题。这其实是好事,因为在团队协作里,统一命名能省掉很多查文件的力气。
3.2 循环里的 index 变量:一个经典的反模式
再看这段代码里的循环:
python复制for index in range(len(rows)):
print(rows[index])
我承认,很多刚从其他语言转过来的人都会这么写,毕竟其他语言里“按下标遍历数组”是基础操作。但 Python 里更推荐直接遍历元素或者使用 enumerate:
python复制for row in rows:
print(row)
Pylint 面对这种写法时会给出 C0200 之类的提示,建议你考虑使用 enumerate 或直接迭代序列。它没有说你的代码“不能运行”,而是在提醒你:这种写法既不够 Pythonic,效率上和可读性上也略逊一筹。
这种检查恰恰是 Pylint 的价值所在。如果只靠人肉 code review,这种问题很可能被忽视,因为代码提交者自己往往不觉得有什么问题。
3.3 那个 if-else 缩进的问题
刚才代码最后有一段:
python复制if order_id > 0:
...
return True
else:
return False
一旦 if 分支内已经 return,后面的 else 就成了多余的结构噪声。Pylint 很可能会建议去掉 else,直接平铺逻辑。如果你用 Black 格式化代码,会更喜欢这种简洁风格,因为它天然减少了缩进层级。
处理这类消息时特别容易走极端。有人会想,既然是 Pylint 的建议,我就照做呗。但实际上,有些 else 虽然技术上多余,但在业务上下文中反而能表达“这是两条对称的分支”,保留它也有可读性收益。我的经验是:Pylint 给出 R 类(重构)建议时,你可以把它当成一个提醒,然后结合业务语义决定是否采纳。而 W 类或 E 类消息,通常意味着真问题,优先级更高。
4. Flake8 的插件生态和与 Black 的组合
Flake8 之所以能流行这么多年,一个很大的原因是它支持插件。你可以基于 Flake8 的框架,定义属于自己的检查规则,也可以直接在社区里找到很多现成的高质量插件。
4.1 我实际会在项目里加的几个插件
下面这几个插件,是我在一个实际业务项目里用下来觉得“风险低、收益高”的,供你参考。
| 插件名称 | 作用 | 典型报错 |
|---|---|---|
| flake8-bugbear | 寻找容易导致 bug 的写法,比如 += 用在可变默认参数上、不安全的 except: pass |
B006:函数定义时使用了可变默认参数 |
| flake8-docstrings | 基于 pydocstyle,检查 docstring 是否需要补全及格式是否规范 | D100:模块缺少 docstring |
| flake8-builtins | 检查变量名是否覆盖了 Python 内置函数名 | A001:变量 list 覆盖了内置函数 |
| flake8-import-order | 检查 import 分组和顺序是否符合规范 | I100:import 语句顺序错误 |
| flake8-annotations | 提示给函数参数和返回值补充类型注解 | ANN001:缺少类型注解 |
刚开始接入 Flake8 时,我最推荐安装的是 flake8-bugbear。它里面的很多规则都指向真实生产中容易踩的坑。比如下面这类代码:
python复制def add_item(item, cache=[]):
cache.append(item)
return cache
函数定义时的默认参数会在模块导入阶段被创建一次,之后所有调用如果不显式传 cache,都会共用同一个列表。这几乎每次都会导致隐蔽的数据污染问题。flake8-bugbear 会直接把这个行为用 B006 标记出来,避免一个棘手的线上 bug。
4.2 安装插件后,配置文件要跟着扩展
装了插件以后,你往往还需要在 .flake8 里追加一些 ignore 项或开关。比如说 flake8-docstrings 一装就会把 docstring 检查全面打开,这时为了和团队实际情况匹配,你就得在忽略列表里写上 D100、D104、D107 这类你暂时不想强制要求的规则。
一个实用的判断方法是:如果你的团队明确了“公共函数要写 docstring,内部函数不强制”,那就在配置文件里忽略内部函数相关的规则,而不是每个人每天手动忽略。把这些规则显式写进 .flake8,新人一进来跑一次就知道团队标准是什么,不需要反复口头解释。
4.3 和 Black 一起用,别提心吊胆
现在很多项目会引入 Black 做自动格式化。Black 会强制统一代码的换行和引号风格,这时候 Flake8 里有一部分 pycodestyle 规则会和 Black 发生冲突。最常见的两处我已经在前面提到了:E203 和 W503。除此之外,有时候 E501(行太长)也会误报,因为 Black 可能在某些括号场景下会把一行撑得很长,但又不能在不破坏语法结构的情况下换行。这种情况下,我用 Flake8 时通常会把 max-line-length 设成与 Black 一致,也就是 88,并且在配置里忽略 E203 和 W503。只要配置文件保持一致,黑盒格式化加上 Flake8 检查就几乎不会有互相打架的地方了。
所以我的最终建议是:Black 管格式,Flake8 管风格错误和语义错误,Pylint 管重构和深层约定。三者各司其职,而不是让其中任何一个工具承担所有职责。
5. 在真实团队里落地的顺序和踩坑记录
把规则和配置都准备好了,最后一步也是最难的一步:怎么在一个已有的、可能有很多历史包袱的团队项目里推进。
我见过不少团队直接把 Pylint 和 Flake8 加到 CI 里,然后全组人一提交代码就发现自己改的文件旁边冒出一堆历史问题。结果不到一周,就有人偷偷在配置里把整个检查关掉,或者干脆合并到主分支后不再理会 CI 失败。这种做法最终会让代码质量工具形同虚设。
5.1 先小范围跑起来,不要试图一次清理所有历史债
我的落地顺序是这样的:
- 新增 Flake8 配置和 Pylint 配置。
- 在本地跑一遍,记录当前所有问题的数量级,但不需要马上清零。
- 给配置设置一个较低的门槛,例如 Pylint
fail-under=5.0,Flake8 先只开 pyflakes 的错误级别 F,不做全量风格强制。 - 发布到团队并约定:旧代码的问题暂时不追责,但新增或修改过的代码不允许引入新的 F 类错误。
- 每隔一到两个迭代,手动挑出当前报错最多、影响最大的模块,专门做一轮清理。
通过这种方式,团队的挫败感会小很多,代码质量也能持续改善。
5.2 用 pre-commit 钩子和 CI 组成一道自动门
如果只在 CI 里跑检查,反馈链路太长,开发者往往要等 push 之后才知道自己有没有写坏。更友好的做法是把 Flake8 放到 pre-commit 钩子里,让它在提交时就拦截明显问题。
以 pre-commit 为例,配置文件中大致会包含这样的段:
yaml复制repos:
- repo: https://github.com/pycqa/flake8
rev: 7.0.0
hooks:
- id: flake8
args: ["--config=.flake8"]
这样每次 git commit 时,pre-commit 会只对暂存区的改动文件跑 Flake8。如果之前配置得当,理论上改动文件里不会出现风格问题,就不会阻塞提交。而 Pylint 因为跑得慢、且更偏深度审查,我一般不建议放在每次 commit 的钩子里,否则每次提交都可能等待十几秒,团队体验很差。更适合的做法是放在 CI 或服务器端的合并请求检查里运行。
5.3 不要轻易使用大范围的 noqa 或 pylint disable
最后想重点说一个反向教训。很多开发者在遇到 Pylint 或 Flake8 报警时,第一反应是打开编辑器,在行尾加一个 # noqa 或 # pylint: disable=some-rule。如果只是针对“确实不想改”的一两行,这种处理没问题。但如果你在一个文件里加了 30 个 noqa,那基本等于告诉后来者:这个文件已经退出质量检查体系了,你想看它内部逻辑,只能碰运气。
更糟糕的是,团队里如果有人习惯把所有警告都用一个大的 disable 屏蔽掉,其他成员会逐渐对代码质量工具失去信心。他们会觉得这不过是个“红灯机器”,反正最后总有办法让它变绿。我对这种行为的处理方式是:在代码评审时要求每个 noqa / disable 后面写上理由。写不出来的,就说明你其实应该去修复这个问题。
5.4 Flake8 不自动修复,但 autopep8 能帮一把
用过 Flake8 的人会意识到,它只是一个检查器,不会帮你把 E 类风格问题自动改掉。如果项目里的空格、空行问题已经积累得很多,建议先用 autopep8 做一次批量修复:
bash复制autopep8 --in-place --aggressive --aggressive src/
然后再跑一遍 Flake8,看看还剩哪些无法自动修复的规则。这里要小心:autopep8 的 --aggressive 次数越多,改动的范围越大,有可能会调整一些你原本觉得就挺好的换行方式。所以执行完以后一定要 review diff,确保没有误伤逻辑结构。Pylint 则没有官方推荐的全自动修复工具,它的很多检查需要你理解上下文后手动处理。
5.5 具体踩过的坑:把 Pylint 跑在整个仓库上导致卡死
有一段时间,我在一个微服务项目里图省事,直接在 CI 命令里写 pylint .,想让它检查整个项目。结果构建时间从原来的 1 分钟直接飙到 5 分钟以上,有些文件多的服务甚至超过 10 分钟。原因很简单:Pylint 做的是整模块级别的分析,当它递归扫描整个仓库时,会导入、解析、分析大量文件,耗时远超我预期。
后来我把运行目标从“全仓库”改成了“本次代码变更涉及的包”,或者在 .pylintrc 中设置 jobs=4 启用并行,构建时间才回到可接受的范围。建议你在项目初期就把要检查的目录白名单固定好,而不是用 . 这种通吃写法,既节省时间,也能避免把 venv、build、__pycache__ 这类目录误包进来。
写在最后的实践经验
我把这套组合用在一个有三年历史的业务系统上之后,最大的变化不是消灭了多少 warning,而是团队开始养成了一个习惯:代码写完以后,会自己先跑一遍 flake8,再跑 pylint --rcfile=.pylintrc。这个过程只需要几十秒,却能把很多低级问题挡在自己手上,不至于把 code review 变成一场“找茬游戏”。
如果你刚开始搭建这套质量体系,我的建议是从小处着手:先把 Flake8 用起来,因为它简单、快、容易理解;Pylint 可以作为第二步,等你熟悉了规则体系以后,再慢慢调高 fail-under 的门槛。最终你会发现,代码质量检查的价值,不在于工具本身有多强,而在于团队愿不愿意为规则达成共识,并且用一个可持续的节奏修复问题。工具只是那个在旁边不断提醒你的“卫士”,最后做决定的,仍然是写代码的你自己。
