接手过老项目的人都懂,代码本身能跑、测试能过,但一改起来就像拆雷。命名乱成一锅粥、一个函数里塞十几个参数、无用的 import 堆了满满一屏,想重构又怕碰坏哪个隐藏逻辑。这种时候,静态检查工具就是最后一道心理防线。Python 生态里最常被拉出来站岗的,就是 Pylint 和 Flake8 这两个名字。一个是出了名的苛刻,一个是出了名的轻快,但很多新手把两者当成“二选一”的单选题,这其实是个很大的误解。这篇文章我会实打实地聊聊这两个工具到底在查什么、怎么搭配用、配置文件怎么写,以及一个真实项目从几百条告警清理到接近零的全过程,顺便把那些网上不会写明白的坑也交代清楚。适合正在给项目引入代码规范、被 lint 告警逼到怀疑人生、或者刚接手历史包袱比较重的老代码库的同学。
1. 为什么需要两道检查?Pylint 和 Flake8 的分工逻辑
1.1 从一次“看起来没问题”的提交说起
先讲一个我真实遇到过的场景。有次同事提交了一个订单导出功能,本地测试、联调、上线全都没问题。三天后另一个同事要在这个模块里加一个字段,翻代码时发现finally块里藏了一个return,外层函数的异常处理等于白写;再往下看,有一个 import 的包从头到尾没用上,还有一个变量名写成了l——没错,就是小写的字母 L,在部分字体下和数字 1 几乎一模一样。
这些问题是测试抓不到的。单测只关心“输入对不对、输出对不对”,不关心代码写得丑不丑、有没有藏雷。而人眼 review 又太依赖个人状态和经验,今天心情好看得细,明天赶进度可能就一眼扫过。Pylint 和 Flake8 这类工具的价值就在于:它们不睡觉、不烦躁、不看人脸色,每次提交都严格执行同一套标准。用我自己的话说,Flake8是守门员,负责拦明显犯规的动作;Pylint是审计师,负责揪出那些“不算犯规但会埋雷”的习惯。
很多团队纠结“到底用哪个”,其实是个伪命题。它们压根不是同一层的东西。
1.2 两个工具做的是不同维度的事
先看一张对比表,后面再展开讲。
| 对比维度 | Pylint | Flake8 |
|---|---|---|
| 底层构成 | 独立开发的静态分析器 | pycodestyle + pyflakes + mccabe 三合一 |
| 检查重点 | 逻辑错误、代码异味、重构建议、命名规范 | PEP8 风格、语法级错误、未使用变量、圈复杂度 |
| 输出信息 | 带消息 ID、符号名和建议代码,还能玩游戏式打分 | 相对轻量,一行一条,路径:行号:列号 代码 |
| 运行速度 | 较慢,全量跑大项目可能需要几十秒甚至更久 | 很快,秒级完成 |
| 误报倾向 | 偏高,常有“教做人”式的抱怨 | 偏低,规则偏向客观事实 |
| 配置复杂度 | 配置项非常多,默认规则偏严 | 配置简单,几条参数就能开工 |
| 与格式化工具兼容性 | 兼容但需要较多配置 | 行宽、缩进等规则容易让开发者想关掉它 |
Flake8的定位是快、准、客观。它检查的内容基本属于“硬伤”:代码风格不符合 PEP8(行太长、缺空行、缩进不对)、模块导入了没用、局部变量赋值了没读、函数圈复杂度超过阈值。这些规则几乎不存在争议——缩进错了就是错了,变量没用就是没用。跑一遍只要一两秒,放进 pre-commit 钩子里几乎感觉不到存在。
Pylint的定位是深、全、严格。它基于 Python 的 AST(抽象语法树)做更细腻的分析,会检查到很多Flake8看不见的东西:函数参数过多、分支复杂度太高、重复代码、except裸捕获、返回语句不一致、动态属性访问不了,甚至还会检查模块和函数的 docstring 写没写。它最“招恨”的 10 分制评分功能,本质上是在给代码的“健康程度”打一个直观的量化分。
所以我的建议从来都是:两个都装,Flake8 把关“能不能提交”,Pylint 把关“要不要重构”。单独用任意一个,都会漏掉另一半问题。Flake8 不会告诉你这个函数逻辑太复杂了,Pylint 不会在你上线前帮你快速拦住那些满含空格的换行。
2. 从零到一:安装、配置与第一轮扫描
2.1 五分钟跑起第一轮扫描
安装没有什么悬念,直接 pip 装就行。建议装在当前项目的虚拟环境里,不要全局装,否则换项目以后配置会互相干扰。
pip install pylint flake8装完后先找一个小项目试一下。我拿一个简单的demo.py举例,里面故意放几个典型问题:
import os import sys def process_data(data): unused_var = 42 if data: print("processing") return True else: return False result = process_data("hello")跑一下 Flake8:
flake8 demo.py输出大概是这样的:
demo.py:1:1: F401 'os' imported but unused demo.py:2:1: F401 'sys' imported but unused demo.py:4:5: E303 too many blank lines (3) demo.py:4:5: C901 'process_data' is too complex (3)每一条都包含文件路径:行号:列号: 规则代码 具体说明。你根本不用查文档,光看说明就知道该怎么改。
再跑一下 Pylint:
pylint demo.py输出就会华丽很多,除了具体的告警列表,结尾还会给一个 10 分制的评分。第一次跑的时候,大多数人都会被那一整屏的C、W、R开头的消息吓到——别慌,这就是它的工作方式,后面会说怎么跟它“讨价还价”。
2.2 把配置文件固定进仓库
跑通一次之后,第一件事不是写业务代码,而是把两个工具的配置固化到仓库里。没有配置文件的 lint 工具就像一个没有规矩的保安,今天按这个标准拦,明天按那个标准拦,队友之间必然吵架。
Pylint 的配置生成很简单:
pylint --generate-rcfile > .pylintrc这个.pylintrc文件会把所有默认配置项都列出来,文件很长,你不需要也没必要全看完。真正需要改的,是下面这几个点。以我常用的配置为例:
[MASTER] ignore = migrations,venv,.git jobs = 4 persistent = no [MESSAGES CONTROL] disable = C0103, C0114, C0116, R0903 [DESIGN] max-args = 8 max-locals = 18 max-returns = 8 [FORMAT] max-line-length = 100逐条说下意图。ignore不用多说,跳过虚拟环境和迁移脚本;jobs = 4是并行解析,跑大项目能快不少;persistent = no是不写缓存文件,CI 环境下更干净。disable里我默认关掉了几个噪音大户:C0103(命名风格)、C0114(缺模块 docstring)、C0116(缺函数 docstring)、R0903(类里只有少部分公共方法)。不是这些规则不应该存在,而是新人刚引入的时候,这些规则会产生大量和历史包袱无关的告警,挫败感极强。等团队适应了,可以再把 docstring 类规则加回来。
Flake8 没有自动生成配置的命令,但你不需要它。直接在项目根目录建一个setup.cfg,或者建一个.flake8文件,内容如下:
[flake8] max-line-length = 100 max-complexity = 12 exclude = .git,__pycache__,migrations,venv extend-ignore = E203,W503我把行宽和 Pylint 保持了一致,都放宽到 100 而不是 PEP8 默认的 79。这里多说一句:79 字符是早期终端宽度的历史遗留,现在的团队普遍用 88(Black 默认)或者 100,只要全项目统一,比纠结“79 才是正统”有意义得多。extend-ignore里的E203和W503是为了兼容 Black 格式化:Black 的冒号前不留空格规则和 E203 冲突,W503和W504则是一对历史上反复横跳的换行符规则,二选一,我选了W503关掉。
配置完记得提交到版本库。这一条我会在所有地方反复强调:配置文件不进仓库,等于没配。
3. 看懂输出,学会和规则讨价还价
3.1 错误代码的暗号,五秒钟快速定位问题
两个工具输出里的那些字母加数字的代码,就是它们的“暗号”。看懂这些,比挨个查文档效率高得多。
Pylint 的消息 ID 用 4 位数字,开头字母对应消息类型:
| 开头字母 | 含义 | 常见例子 |
|---|---|---|
| C | convention 约定 | C0103 命名不规范、C0116 缺函数 docstring |
| R | refactor 重构 | R0913 参数过多、R0914 局部变量过多、R0911 返回语句过多 |
| W | warning 警告 | W0611 未使用的导入、W0612 未使用的变量、W0702 裸 except |
| E | error 错误 | E0602 使用未定义的变量、E0401 无法导入模块 |
| F | fatal 致命 | 通常是分析器本身无法处理某些语法结构 |
我的经验是:E 和 F 类是无条件要修的,这是真 bug;W 类 90% 要修,属于“以后必炸”的雷;R 类值得开会讨论,属于“确实该重构但排期另说”;C 类看团队约定,命名风格这种东西,统一规则比绝对正确更重要。
Flake8 的代码前缀则更简单直接:
| 开头字母 | 所属检查器 | 常见例子 |
|---|---|---|
| E | pycodestyle 风格错误 | E501 行太长、E303 空行过多、E128 延续行缩进错误 |
| W | pycodestyle 风格警告 | W503 运算符前换行、W504 运算符后换行 |
| F | pyflakes 逻辑检查 | F401 导入未使用、F841 局部变量未使用、F821 未定义的名字 |
| C901 | mccabe 复杂度 | 函数圈复杂度超标 |
有一个很关键的提示:Flake8 的F开头规则和 Pylint 的W0611、W0612有大量重叠,但它们查重的方式不同。我处理这类告警时有一个原则:Flake8 报的 F 类一定马上改,因为它的分析器非常保守,报出来就是板上钉钉的事实;Pylint 报出来的 W 类先看一眼,偶尔会有误判。
3.2 处理误报的正确姿势
说实话,lint 工具用久了,你会遇到一些“理论上该报、实际上我不需要改”的情况。最常见的误报来源是动态属性。比如用 Django 的时候,request.user、Model.objects这类由框架自动生成的属性,Pylint 是不认识的,会给你报E1101: Instance of 'WSGIRequest' has no 'user' member。你在.pylintrc的MASTER段里加上:
generated-members = objects,user,request.GET,request.POST把框架动态挂上去的属性和方法加到这个列表里,误报就消停了。
还有一类误报是业务场景决定的。比如一个接口函数定义了 9 个参数,R0913(参数过多)肯定会响。但如果你需要兼容旧客户端调用,不能随便改签名,这时候用局部豁免:
def update_user(user_id, name, email, phone, address, avatar, tags, source, remark): # pylint: disable=too-many-arguments ...Pylint 支持在代码行尾加# pylint: disable=规则名,按行精准豁免。Flake8 对应的语法是行尾加# noqa,也可以更精确地写# noqa: F401只豁免某一条规则。
这里非常重要的一点是:豁免必须精准到规则、到行,不允许一个 disable 放倒整片。我见过有人图省事,在配置里加了disable = all,结果工具变成了一个纯摆设,所有问题都从防线下面溜过去。这种“仪式性 lint”比不装工具危害更大——它给了团队一种虚假的安全感。
另外,和 Black 格式化工具一起用的时候,会有几个固定冲突场景。Black 默认行宽 88,Pylint 默认 79,Flake8 默认 79,三个标准如果不统一,Black 刚格式化完,Flake8 就开始报 E501。解决办法就是前面说的,在 Pylint 和 Flake8 里都把max-line-length改成 88 或者 100,和 Black 保持一致。还有 Black 会把集合里的元素写成一个一个独立行,这种格式在某些 Flake8 版本里会触发 E203,所以我在配置里把E203加入extend-ignore。
4. 实战:一个老项目从 400 条告警到零的完整流程
4.1 先跑一遍全量扫描,把告警分类归档
理论讲完,上真实案例。之前我接手过一个基于 Flask 的管理后台,大概 60 个模块、2 万行 Python 代码。历史原因,这项目基本没跑过任何静态检查。第一次全量扫描的时候,Flake8 给出了 380 多条告警,Pylint 打了一个让我印象深刻的 3.9 分。
当时的处理方式,是把告警分成四类,按优先级推进。
| 优先级 | 告警类型 | 大约数量 | 处理方式 |
|---|---|---|---|
| P0 | Flake8 F 类(逻辑问题) | 30 条左右 | 全部当场修,有 10 多条确实是潜在 bug |
| P1 | Flake8 E/W 类(风格问题) | 230 条左右 | 花一到两天用脚本批量修,部分手动 |
| P2 | Pylint E 类 | 20 条左右 | 全部修,其中几条和 F 类重叠 |
| P3 | Pylint W/R/C 类 | 剩下所有 | 分阶段修,允许先加豁免再慢慢消化 |
P0 里最典型的几个:F821 undefined name有两处,代码里引用了一个根本没定义的变量,因为被try/except包着,运行时机缘巧合没走到那条分支,属于运气好没炸的雷;F841 local variable is assigned to but never used有十几处,大多数是调试时留下的中间变量,直接删;还有一个F811 redefinition of unused,同一个函数在文件里被定义了两次,后一个覆盖前一个,如果哪天有人想调用旧版本,会发现怎么行为不对。
这个阶段我强烈建议用脚本批量统计告警分布,而不是手动翻输出。跑一遍:
flake8 --statistics它会按规则代码分组统计数量,比如E501出现 45 次、F401出现 18 次。这样你就能快速找到性价比最高的修复方向——先把出现次数最多的那类问题批量解决,告警数字会下降得非常快。
4.2 分批推进,每轮只动一类告警
我当时的节奏是这样的,你可以直接抄作业。
第一批:修所有 Flake8 的 F 类。这是纯逻辑问题,必须人肉看。30 条左右,花了一天。
第二批:修 Flake8 的 E/W 类风格问题。这类大多是缩进、空行、行尾空格。我写了个一次性脚本自动修能修的,剩下手改。E501(行太长)这类不能盲目拆,拆不好反而影响可读性,我通常把长字符串提取成变量,或者用隐式字符串拼接。
第三批:修 Pylint 的 E 类。和 F 类重复的跳过,重点处理E0401(无法导入模块)。当时有一批E0401是因为代码里用了sys.path手工拼接路径导入兄弟目录模块,Pylint 在纯静态分析时找不到。这个不是代码 bug,但暴露了项目结构的问题。我顺手把这些路径导入改成了用相对导入,既消了 lint 告警,也让模块结构更清晰。
第四批:处理 Pylint 的 W、R、C 类。到这里已经不会全改了,我的策略是:
W0611(未使用的导入)和F401重叠,Flake8 已经清掉,所以 Pylint 很少再报。W0612、W0613(未使用的变量、参数)走一遍看是不是真没用,是就删。R0913(参数过多)这类,属于“明知要改但不敢动”的,我先在.pylintrc里把max-args放宽到 8,然后把少数几个确实需要重构的记到技术债清单里,后续排期处理。C0103(命名规范)直接全局 disable,因为老项目里数据库字段映射的名字,改起来影响面太大,收益有限。
每修完一批,跑一次全量扫描对比数量。修复期间最少 commit 一次,保证每步都能回滚——我用 Git 的 diff 检查一个原则:这次提交只包含对告警的修复,不夹带任何功能改动。否则两个问题搅在一起,出了 bug 没法排查。
4.3 用“基线”策略拦住新代码
存量代码清完一波之后,还有一个更重要的问题:怎么防止新代码把告警数拉回去。
我用的方法是baseline 基线策略。具体操作是:把这次清理后的告警情况保存下来,后续每次扫描只关注“新增告警”。
做法很简单。假设 Pylint 全量扫描后的评分稳定在 9.0 以上,就把它作为 CI 的阈值:
pylint myapp/ --fail-under=9.0这条命令的意思是:评分低于 9.0 就直接失败,代码合不进去。Flake8 那边更直接:
flake8 myapp/ --count --max-complexity=12--count会在最后打印一个总告警数。CI 里可以配合 shell 判断:告警数大于 0 就非零退出。
但注意,这种策略有一个漏洞:老项目可能还有少量历史遗留告警没清完,如果阈值写死为 0,新人和旧代码都会很痛苦。所以更稳妥的过渡方案是:先把存量告警冻结在一个“允许列表”里,后续只阻止新增。Flake8 没有原生支持 baseline 文件,但社区有flake8-baseline这类工具可以做;Pylint 则可以通过disable列表配合“只检查 diff 行”的 CI 插件实现。我的建议是:如果团队刚起步,先用“阈值”方案,等告警数沉到个位数再换“允许列表”方案,体验最平滑。
5. 常见问题与排查技巧实录
5.1 问题速查表
把我在实际使用中踩过、见别人踩过的坑整理成一个速查表,按“现象 -> 原因 -> 解决”来看即可。
| 现象 | 原因 | 解决 |
|---|---|---|
| 配置文件改了但没生效 | 没有指定路径,或者文件名不对 | Pylint 用--rcfile=显式指定;Flake8 支持setup.cfg、.flake8、tox.ini,但项目里有多个文件时,以命令行--config为准 |
| Black 格式化后 Flake8 报 E203/E501 | 工具间默认行宽和空白规则不一致 | 统一max-line-length,extend-ignore = E203,W503 |
| Pylint 跑得很慢 | 没有并行、文件多、重复扫描 | 加jobs = 4,persistent = no,或者用--files-output=yes分开输出 |
| Pylint 在 CI 里报 unable to import | 依赖没装全,或虚拟环境路径不对 | 在 CI 里先pip install -r requirements.txt,再给 Pylint 配init-hook把项目根目录加进sys.path |
| Flake8 默认不检查 pyproject.toml | Flake8 原生不支持 | 用.flake8文件代替;或者装插件flake8-pyproject |
| 同一段代码本地不报、CI 报 | 本地和 CI 版本不一致 | 把两个工具的版本固定到requirements-dev.txt,或者用 pre-commit 统一锁版本 |
| F401 和 W0611 重复报 | 两个工具查同一件事 | 不用管,各查各的;如果想省事可以在 Pylint 里 disable 掉W0611,反正 Flake8 会拦 |
| 想要项目忽略某个文件 | 个别文件不适用规范 | Pylint 配ignore;Flake8 配exclude;也可以给特定文件行尾加# noqa |
有一个非常容易被忽略的细节:Flake8 默认不会扫描隐藏目录,但如果你在.flake8里配置了exclude,注意它不会自动附加默认排除项,而是覆盖默认值。我踩过一次:配了exclude = .git,__pycache__后,忘了把venv加上,结果扫描把虚拟环境里几百个文件全算进去,告警数直接爆表。后来我写成exclude = .git,__pycache__,venv,migrations才消停。
5.2 让检查变成团队习惯的最后一公里
工具本身不会改变团队习惯,把工具嵌进日常流程才会。我最推荐三个地方:pre-commit 钩子、CI 流水线、编辑器保存时自动检查。
先说 pre-commit。项目根目录建一个.pre-commit-config.yaml:
repos: - repo: https://github.com/pylint-dev/pylint rev: v3.2.6 hooks: - id: pylint args: ["--fail-under=9.0"] - repo: https://github.com/pycqa/flake8 rev: 6.1.0 hooks: - id: flake8 args: ["--max-line-length=100", "--max-complexity=12"]团队成员只需要pre-commit install一次,后续每次git commit都会自动跑检查,跑不过就不让提交。相比 CI 在推送后才报错,这种方式把发现问题的成本提前到了本地,体验好太多。
再说 CI。我自己常用的 GitHub Actions 长这样:
- name: Lint with Pylint and Flake8 run: | pip install pylint flake8 pylint myapp/ --fail-under=9.0 flake8 myapp/ --countGitLab CI 里也差不多,核心就fail-under和--count这两个参数。CI 的价值是兜底:防止有人本地绕过 pre-commit(我见过直接把钩子卸载的),保证主分支上的代码永远符合最低标准。
最后是编辑器集成。VS Code 里装 Python 扩展后,跑一条命令:
"python.linting.pylintEnabled": true, "python.linting.flake8Enabled": true, "python.linting.lintOnSave": true保存即检查,问题直接在底部面板里列出来,红色波浪线指到具体位置。这个交互反馈比任何文档都管用,新人进团队的时候,被编辑器里的红线“教”几次,自然就学会了规范。
结尾
最后说点个人的大实话。在团队里推 lint 工具,最难的不是配置,而是让大家接受“被机器挑刺”这件事。我做过最有效的一件事,不是发规范文档,也不是写自动化脚本——而是在周会上演示了一次:把 Flake8 报出来的几个 F 类告警,对应回线上真实出现过的故障,让所有人看到“这些检查不是形式主义,是真的能在故障发生前拦住问题”。从那以后,团队对新告警的态度从“烦”变成了“感谢”。几年下来我的体会是:工具的价值从来不是把评分从 3.9 刷到 10,而是让没必要的低级错误根本进不了代码库,让人的精力能集中在真正值得花时间的重构和设计上。如果你刚开始在自己的项目上配置这两个工具,就先从max-line-length = 100、默认规则全开、Flake8 改到 0 告警开始,这已经能拦住相当大比例的“看起来没问题”。剩下的,等真正遇到了问题再慢慢调。