简介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 从源码到 ASTast.parse 之后你拿到了什么Python 标准库自带ast这是所有检查器的地基。给它一段字符串它返回一棵节点树每个节点带类型和行号后续规则全部挂在这棵树上跑。import ast source def calc(items): total 0 for it in items: total it.value return total # filename 只影响报错信息里的文件名modeexec 表示按模块解析 tree ast.parse(source, filenamedemo.py, modeexec) # 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: disableunused-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注入变量。解决把动态注入收敛到一个模块只在该模块内用文件级disableundefined-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-filterACM origin/main...HEAD \ | grep \.py$ \ | xargs -r pychecker check --config pyproject.tomlorigin/main...HEAD是三点 diff含义是“从共同祖先到当前 HEAD 的变更”比两点 diff 更适合分支场景不会把主干上的新提交算进来。--diff-filterACM只保留新增、复制、修改的文件删除的文件不需要检查。xargs -r在输入为空时不执行命令避免空跑报错。这里有个容易忽略的点增量检查只覆盖变更文件被变更文件引用的模块如果本身有未定义变量增量模式是发现不了的。所以我的习惯是本地用增量求快CI 每天定时跑一次全量并更新报告两者分工不互相替代。基线也不是一劳永逸的。每次大版本重构、批量重命名之后基线里的行号会整体漂移比对结果会冒出一批“假新增”。稳妥做法是重构分支合并后立刻刷新一次基线刷新前先人工过一遍新增告警确认没有把真问题冻进去。刷新动作写进发布 checklist比事后拍脑袋靠谱。从那以后我每次接新项目第一件事就是把全量扫描跑一遍、基线落下来、CI 接上--fail-on new再谈规则调优。顺序反了后面全是补丁。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?