简介:Pychecker 0.8.19 是面向 Python 开发者、代码审查人员及教学使用者的静态代码分析工具包,适合在程序运行前排查类型不匹配、未定义引用、未使用变量、过大函数与类、模块循环依赖、复杂表达式与深嵌套结构、异常处理不当等常见问题,帮助中小型项目提升代码可读性与可维护性。压缩包共 54 个文件,约 176KB,其中 41 个为 Python 源码文件,另含配置样例、版本与变更记录、已知问题说明、维护者信息及测试用例等辅助资料,便于读者对照理解检查规则、警告输出和实现方式。已有 391 人学习下载。读者可借助源码与测试样例了解静态检查机制,验证规则覆盖范围,也可为自建检查脚本提供参考,并将其作为 Pylint、Flake8 之外的补充工具,用于日常代码审查、问题定位和代码质量改进。
1. pychecker 工具:上线前夜那个 NameError,让我重新捡起静态检查
去年一个模拟项目 X 灰度发布,日志里蹦出一个 NameError,位置在一条几乎没人改过的分支里。回滚、定位、补丁,前后四十分钟,根因只是一行拼错的变量名。python 代码检查这件事,很多人靠运行时报错兜底,可线上才暴露的成本太高。pychecker 工具做的就是把这层兜底前移:不执行代码,只解析语法树和作用域,把未定义变量、未使用导入、可疑分支、裸 except 这类问题提前打出来。它适合两类人,一类是写运维脚本和数据处理脚本、没有完整测试覆盖的工程师,另一类是要给老项目接 CI 闸门、又不想一上来就引入重型框架的维护者。
2. 检查器的工作方式:AST、符号表与三层规则边界
静态检查听起来玄学,拆开看其实只有两步:把源码变成结构化的树,再在树上做名字和路径的判断。之所以不能靠正则扫源码,是因为if、for、闭包、推导式都会改变名字的可见范围,正则分不清一个x是模块级常量还是函数里的临时变量。pychecker 这类工具的稳定性,取决于它把“语法结构”和“作用域语义”分得有多清楚。
2.1 从源码到 AST:ast.parse 之后你拿到了什么
Python 标准库自带ast,这是所有检查器的地基。给它一段字符串,它返回一棵节点树,每个节点带类型和行号,后续规则全部挂在这棵树上跑。
import ast source = ''' def calc(items): total = 0 for it in items: total += it.value return total ''' # filename 只影响报错信息里的文件名,mode="exec" 表示按模块解析 tree = ast.parse(source, filename="demo.py", mode="exec") # walk 是广度优先遍历,只用来快速摸底,正式规则用 NodeVisitor for node in ast.walk(tree): if hasattr(node, "lineno"): print(type(node).__name__, node.lineno)这段代码的重点不是打印,而是让你看到:total += it.value在树上是一个AugAssign,左边是Name,右边是Attribute。AST 能告诉你“这里访问了一个属性”,但它不知道it到底有没有value,也不知道total在前面有没有被赋值。这就是纯语法层的天花板,也是为什么只看 AST 的检查器误报率下不来。
ast.parse有三个常用参数。source是源码字符串;filename会写进节点的位置信息,报错时能对上文件;mode取exec按模块解析,取eval按单个表达式解析,取single用于交互式输入。做代码检查一律用exec,否则函数定义和多语句块会直接抛语法错误。
2.2 符号表:未定义变量为什么必须在作用域链上判断
AST 只描述“长什么样”,符号表描述“名字绑到哪”。Python 标准库的symtable会把每个作用域拆成一张符号表,告诉你某个名字是局部赋值、全局引用还是闭包捕获。pychecker 判断未定义变量时,走的就是这条链路。
import symtable source = ''' LIMIT = 10 def process(rows): result = [] for row in rows: if row > LIMIT: result.append(row) return result ''' # 第三个参数与 ast.parse 的 mode 对齐,模块级一律用 exec table = symtable.symtable(source, "demo.py", "exec") for child in table.get_children(): print("函数:", child.get_name()) for sym in child.get_symbols(): # is_local / is_global / is_free 描述名字的绑定来源 print(" ", sym.get_name(), "local=", sym.is_local(), "global=", sym.is_global(), "free=", sym.is_free())输出里LIMIT的is_global()为真,result和row的is_local()为真。检查器要做的,是在遇到一个Name节点时,沿当前作用域逐层向上找:本层没有、外层没有、内置名字里也没有,才判定为未定义。少走一层就会误报,多走一层就会漏报。
这里有个容易翻车的细节:Python 在编译期就把函数体内出现过的赋值名全部标记为局部变量,跟赋值语句的位置无关。所以print(x); x = 1不会去读全局的x,而是直接抛UnboundLocalError。静态检查要识别这种“局部但未初始化”的模式,靠的不是看名字有没有出现过,而是看它在赋值前有没有被读取。这也是符号表比字符串匹配靠谱的地方。
2.3 三层规则各管什么:语法、语义、风格不要混在一个开关里
同一个检查器里塞几十条规则,最怕的是一开全量、满屏告警、团队直接关掉。比较稳的做法是按检测手段分三层,每层单独配置严重级别。
| 层级 | 典型问题 | 检测手段 | 误报特征 |
|---|---|---|---|
| 语法层 | 括号不匹配、缩进错误、非法字面量 | compile/ast.parse | 几乎为零,错了就是错了 |
| 语义层 | 未定义变量、未使用导入、裸 except、不可达代码 | AST + 符号表 | 中等,动态赋值场景集中 |
| 风格层 | 行长度、命名规范、导入顺序 | tokenize+ 规则表 | 偏高,强依赖团队约定 |
语法层适合当硬闸门,解析不过直接退出,不进入后续流程。语义层是 pychecker 的主力,也是收益最高的部分,建议在 CI 里设为 error 级。风格层不要一上来就卡,先跑报告模式观察两周,把团队真正在意的几条挑出来,其余关掉。三层混在一个开关里的后果,是新人第一次提交就收到三百条告警,从此把检查器当噪音。
3. 把 pychecker 跑起来:隔离环境、钉死范围和退出码
拿到分发包之后,最忌讳的是直接往全局 Python 里装。检查器本身会依赖解析库和配置解析库,版本一冲突,排查成本比写业务代码还高。我一般按“环境隔离、配置入仓、退出码分流”三步走,先把最小可用链路跑通,再谈规则扩展。
3.1 环境隔离与安装:venv 里装,不要污染全局
# 建独立环境,Python 版本跟项目运行环境保持一致 python3 -m venv .venv source .venv/bin/activate # 升级 pip,老版本对 wheel 的兼容性差 python -m pip install --upgrade pip # 用项目自带的分发包安装,避免不同机器拉到不同版本 pip install ./dist/pychecker-*.whl # 如果拿的是源码目录,用可编辑安装,方便后续改规则 # pip install -e .虚拟环境的作用不只是隔离依赖,更重要的是让“检查器版本”变成可复现的东西。团队里有人用全局安装、有人用系统包管理器装,最后同一份代码在两个人机器上告警数不一样,这类问题查起来非常耗时间。Windows 下激活命令换成.venv\Scripts\activate,其余步骤一致。
安装完先跑一次pychecker --version确认入口可用。如果命令找不到,多半是Scripts或bin目录没进 PATH,激活虚拟环境后一般会自动处理;仍然不行就用python -m pychecker走模块入口,效果一样。
3.2 最小配置:pyproject.toml 里把检查范围钉死
配置文件不入仓,等于没配置。pychecker 支持从pyproject.toml读取配置,把检查范围、规则开关、严重级别一次性写清楚,本地和 CI 才有共同基准。
[tool.pychecker] # 只检查源码和脚本目录,构建产物、虚拟环境一律排除 include = ["src", "scripts"] exclude = ["src/generated", "*/migrations/*", ".venv"] # 第一轮只开语义层,风格层先观察 enable = [ "undefined-name", "unused-import", "bare-except", "unreachable", ] # 严重级别分开配,error 卡流水线,warning 只出报告 severity = { undefined-name = "error", unused-import = "warning" } # 单文件超过阈值跳过,防止老代码拖慢全量扫描 max-lines = 3000include和exclude的匹配基准是配置文件所在目录,不是当前工作目录。这一点在 CI 里特别关键:流水线如果在子目录里执行命令,相对路径会整体偏移,扫描范围可能变成空集,表现为“本地有告警、CI 全绿”,反过来也可能把构建产物扫进来。稳妥做法是配置里统一用仓库根目录的相对路径,CI 里先cd到仓库根再执行。
severity建议只把undefined-name和bare-except设为 error。unused-import在__init__.py里误报较多,先设 warning 观察一段时间,等确认误报来源后再收紧。max-lines不是拍脑袋定的,可以先跑一次全量,看扫描耗时分布,把明显拖后腿的大文件挑出来单独处理。
3.3 命令行与退出码:接进 CI 的第一道闸
# 本地只检查本次变更,降低提交前等待时间 pychecker check --config pyproject.toml --diff HEAD~1 # CI 里全量扫描,输出 JSON 供流水线解析和归档 pychecker check --config pyproject.toml --format json --output report.json # 单独取退出码,区分“有问题”和“工具自身出错” echo "exit=$?"退出码要分三层看:0表示没有 error 级问题;1表示检出了 error 级问题,属于代码该修;2表示配置错误、依赖缺失或内部异常,属于工具链该修。很多团队把1和2都当成失败,结果配置写错的时候,排查方向全被带到业务代码上。CI 脚本里建议显式判断,2单独发告警给维护检查器的人。
--diff参数的语义是“只检查变更行涉及的问题”,适合 pre-commit 这种对耗时敏感的场景。它不能替代全量扫描,因为存量问题不会因为没人改就消失。比较合理的组合是本地用--diff快速反馈,CI 每天跑一次全量并归档报告。
4. 自定义规则开发:从 AST 访问器到报告输出
内置规则覆盖的是通用问题,真正体现团队约束的,往往是自己写的几条。pychecker 的规则层是开放的,核心就是继承访问器、实现visit_*方法、通过report抛出问题。写规则之前先想清楚两件事:这条规则要抓的具体模式是什么,误报的边界在哪里。想不清楚就先别写成 error 级。
4.1 写一个 AST 访问器:检测裸 except
裸except:会连KeyboardInterrupt和SystemExit一起吞掉,是线上排障的经典黑匣子。下面这条规则同时抓两种模式:完全裸的except:,以及except Exception: pass这种等价于吞异常的写法。
import ast from pychecker.rule import Rule, register @register("bare-except") class BareExceptRule(Rule): """禁止裸 except,以及捕获后直接 pass 的写法""" def visit_ExceptHandler(self, node: ast.ExceptHandler): # type 为 None 说明是裸 except: if node.type is None: self.report(node, "禁止裸 except,请显式捕获具体异常类型") else: # 捕获 Exception 后只有一条 pass,等价于静默吞掉 body = node.body if (isinstance(node.type, ast.Name) and node.type.id == "Exception" and len(body) == 1 and isinstance(body[0], ast.Pass)): self.report(node, "except Exception 后直接 pass,至少记录日志") # 继续向下遍历,保证嵌套 try 也能被检查到 self.generic_visit(node)visit_ExceptHandler是访问器的分发入口,遇到except节点自动调用。node.type为None是最可靠的裸 except 判据,不要用源码字符串匹配,因为注释和换行会干扰。第二个分支判断except Exception: pass,条件是类型名是Exception、异常体只有一条语句、且该语句是Pass。generic_visit(node)不能省,省了之后嵌套结构里的异常处理会被漏掉。
self.report一般接收三个信息:节点(用于取行号和列号)、消息、可选规则名。行号从node.lineno取,列号从node.col_offset取,报告里能直接定位到源码位置。
4.2 规则注册表与配置读取
规则写完后要能被工具发现,常见做法是维护一张显式注册表,而不是在 import 时自动扫描。自动注册看起来省事,实际上会让加载顺序和副作用变得不可控,调试时很难判断某条规则到底有没有生效。
# pychecker_rules/__init__.py from .bare_except import BareExceptRule # 显式列出,新增规则必须改这里,避免隐式加载 RULES = [BareExceptRule] def load_rules(config): """按配置里的 enable 列表实例化规则""" enabled = set(config.get("enable", [])) return [cls() for cls in RULES if cls.name in enabled]load_rules的入参是解析后的配置字典,返回规则实例列表。用cls.name做过滤,要求每条规则在定义时把name属性和注册名保持一致,否则配置里写bare-except却匹配不到。这类不一致不会抛异常,只会表现为“规则没生效”,排查时先打印load_rules的返回值确认。
配置读取要注意默认值。config.get("enable", [])在缺省时返回空列表,意味着不启用任何规则,这比默认全开安全。默认全开的问题是,团队升级工具版本后新增的规则会自动生效,流水线突然变红,而变更日志里根本没提。
4.3 报告输出与行内抑制
任何检查器都需要抑制机制,关键是抑制的粒度要小、要留痕。pychecker 支持行级noqa和文件级disable,前者只影响当前行,后者影响整个文件。
# 行级抑制,只对当前行生效,下一行恢复检查 result = risky_call() # noqa: undefined-name # 文件级抑制放在文件头部,必须写清原因和负责人 # pychecker: disable=unused-import # 兼容旧版 Python 的条件导入,负责人 A # 不写规则名的 noqa 会抑制该行所有告警,风险最高,建议在评审里拦掉 value = dynamic_lookup() # noqanoqa后面的规则名要和配置里的enable名称一致,写错等于没写。文件级抑制只建议用在两类场景:兼容多版本 Python 的条件导入,以及自动生成的代码。其余情况一律用行级抑制,并且要求带原因。团队里可以加一条元规则,扫描所有noqa注释,缺规则名或缺原因的直接在 CI 里报出来。
5. 避坑清单:五类误报漏报的现场记录与处理
静态检查的坑,八成集中在“静态分析看不到运行时的动态行为”这一件事上。下面五条是实际项目里反复出现的模式,每条按现象、原因、解决三步记,遇到同类问题可以直接对号入座。
5.1 动态赋值被误判为未定义
现象:模块里用globals()["cfg"] = load_config()注入配置,后续函数引用cfg时,检查器报undefined-name,但代码运行完全正常。
原因:符号表是静态构建的,globals()的写入发生在运行时,分析阶段看不到这个绑定。同类问题还有setattr动态挂属性、exec注入变量。
解决:把动态注入收敛到一个模块,只在该模块内用文件级disable=undefined-name,其余模块照常检查。不要图省事在全局配置里关掉这条规则,那等于把最有价值的一层防护整个拆掉。
5.2 条件分支里的变量可能未赋值
现象:if分支里给变量赋值,else分支不赋值,函数后面直接用这个变量,检查器没有报错,运行时在特定输入下抛UnboundLocalError。
原因:简单实现只判断“函数内有没有赋值”,不分析控制流路径。只要出现过一次赋值,名字就被当作已定义。
解决:开启控制流敏感检查,或者对返回路径做人工评审。更彻底的做法是约定“分支内赋值必须带初值”,从写法上消除这类路径。检查器能做的有限,别指望它替代测试。
5.3 大文件全量扫描慢到怀疑人生
现象:单文件几千行,扫描耗时从秒级涨到分钟级,CI 超时。
原因:每条规则各遍历一次 AST,规则数量一多就是乘法效应;再加上没有缓存,每次全量重扫。
解决:把多规则合并成单次遍历,按节点类型分发;配置max-lines跳过明显不该纳入检查的生成文件;用文件内容哈希做缓存,未变更的文件直接复用上次结果。这三条做完,耗时通常能降一个数量级。
5.4 本地过、CI 不过
现象:同一份代码,本地零告警,CI 报出一堆,且报的位置看起来跟改动无关。
原因:检查器版本不一致、配置里相对路径基准不同、CI 把构建产物或第三方目录扫了进来,三种情况都常见。
解决:锁版本,分发包入仓或用内部源固定;配置里统一用仓库根目录相对路径;CI 脚本开头先打印实际扫描的文件列表,范围不对一眼就能看出来。这条属于血泪经验,排查过一次就会养成打印文件列表的习惯。
5.5 noqa 滥用,抑制注释变成遮羞布
现象:项目跑了半年,noqa数量超过规则本身的告警数,新人看到告警第一反应是加抑制而不是修代码。
原因:抑制没有 owner、没有原因、没有过期时间,加的时候没人拦,清理的时候没人认领。
解决:要求noqa必须带规则名和原因,文件级抑制必须带负责人;加一条元规则定期扫描并输出抑制清单,按负责人分组;每个迭代清理一批,清不掉的在评审里说明理由。抑制机制本身没问题,问题在于没有成本。
6. 增量检查与基线管理:让存量问题不再滚雪球
全量扫描最尴尬的地方在于,老项目第一次跑必然满屏告警,直接卡 CI 等于让所有人停下来还债,不卡又等于检查器白装。比较务实的路径是“基线冻结存量、增量拦截新增”,先让流水线转起来,再按迭代慢慢还。
第一步是把当前所有告警落成基线文件:
# 首次生成基线,把当前所有告警冻结为“已知问题” pychecker check --config pyproject.toml --format json --output baseline.json # 之后每次检查与基线比对,只对新增告警失败 pychecker check --config pyproject.toml \ --baseline baseline.json \ --fail-on new--baseline的语义是“基线里已有的告警不重复报”,--fail-on new表示只在出现新增告警时返回非零退出码。这两个参数配合,CI 从第一天起就能卡住新问题,而存量债务按团队节奏处理。基线文件要入仓,且刷新必须单独提交,方便追溯是哪次刷新引入了哪些告警。
第二步是做增量检查,减少本地等待:
# 取与主干的差异文件,只把 Python 文件交给检查器 git diff --name-only --diff-filter=ACM origin/main...HEAD \ | grep '\.py$' \ | xargs -r pychecker check --config pyproject.tomlorigin/main...HEAD是三点 diff,含义是“从共同祖先到当前 HEAD 的变更”,比两点 diff 更适合分支场景,不会把主干上的新提交算进来。--diff-filter=ACM只保留新增、复制、修改的文件,删除的文件不需要检查。xargs -r在输入为空时不执行命令,避免空跑报错。
这里有个容易忽略的点:增量检查只覆盖变更文件,被变更文件引用的模块如果本身有未定义变量,增量模式是发现不了的。所以我的习惯是本地用增量求快,CI 每天定时跑一次全量并更新报告,两者分工,不互相替代。
基线也不是一劳永逸的。每次大版本重构、批量重命名之后,基线里的行号会整体漂移,比对结果会冒出一批“假新增”。稳妥做法是重构分支合并后立刻刷新一次基线,刷新前先人工过一遍新增告警,确认没有把真问题冻进去。刷新动作写进发布 checklist,比事后拍脑袋靠谱。
从那以后我每次接新项目,第一件事就是把全量扫描跑一遍、基线落下来、CI 接上--fail-on new,再谈规则调优。顺序反了,后面全是补丁。希望帮到你。
本文还有配套的精品资源,点击获取