)
最近在帮团队整理一批数据分析脚本发现一个特别普遍的毛病每个文件里都躺着七八行调试残留——print(数据加载完成)、df.head()、df.show()还有从 Jupyter 直接导出的to_html()。这些代码留着没用扔到生产环境还会往日志里刷一堆脏东西。手动删吧几十个文件一个个点下来既无聊又容易误删CtrlF 搜print又怕连正式输出一起干掉。于是我把 Python 的 AST 拿出来搞了一个自动清理工具专治这类调试完忘了摘掉的代码顺手也把display()、pprint()这类交互式环境常用的查看语句一起处理了。这篇文章就把整个思路、完整代码和踩过的坑都记录下来适合正在做数据分析脚本清理、想把 Notebook 转成正式脚本、或者对 AST 自动化重构感兴趣的 Python 使用者。1. 调试残留代码每个 Python 人都会遇到的头疼事1.1 这堆看着有用其实没用的代码是怎么来的先别急着写代码我们得搞清楚要清理的对象长什么样。调试残留不是说某一种特定的函数而是指那些只在探索阶段有意义、交付阶段完全多余的表达式语句。最常见的几类print(...)最简单的日志输出数据分析时看中间结果调试完忘了删。df.head()Pandas 里查看 DataFrame 前几行Notebook 里常用但它不是赋值语句只是看一眼。df.show()Spark DataFrame 的显示方法在 PySpark 场景下高频出现。df.to_html()Jupyter 里把 DataFrame 渲染成 HTML 表格其实也是看一眼。display(obj)、pprint(obj)Notebook 和交互式调试的产物。它们的共同特征是一般作为独立表达式语句出现不产生后续被引用的结果不像df pd.read_csv()那样有赋值语义。你可能会问为什么不直接用正则匹配print\(.*\)然后删掉这个想法我一开始也试过正则的方案听着简单但在真实代码里撞得头破血流。1.2 正则为什么搞不定清理工作假设你自信满满地写了re.sub(rprint\(.*?\), , source)很快会遇到几个经典事故第一嵌套括号。print(f结果: {df.describe().to_string()})这种写法非贪婪匹配会在第一个)就断掉剩下的尾巴全留在行里生成一堆残缺语句。第二字符串里的假print。代码里如果有message print(hello)或者文档字符串里写了print(正则会把它们也当目标干掉。也许有人说可以加上引号状态判断但状态机写起来已经快赶上解析器了。第三多行表达式。print(后面接十几个参数的写法在日志代码里太常见了正则既要匹配多行又要处理缩进复杂度直线上升。第四也是最根本的正则看不懂这是语句还是表达式的一部分。同样是print(x)独立一行时需要删出现在if debug: print(x)里可能也需要删但出现在result print_and_return(x)里只是调用名的一部分完全不该动。正则只会按字符模式硬切它不理解代码结构。那怎么办Python 其实给每个源码都建了一份结构图纸这就是 ASTAbstract Syntax Tree抽象语法树。我们要做的是读图纸然后精确地拆掉图纸上那些标记为调试残留的节点。2. AST 基础把源码变成一棵能下手的树2.1 从源码到 AST 的编译链路任何一个 Python 程序在被执行之前都要走这么一条路源码字符串 - 词法分析成 token - 语法分析成 AST - 编译成字节码。AST 位于中间层它已经摆脱了逗号、缩进、换行这些表面细节只剩下纯粹的语义结构。可以这样类比源码是装修好的房子AST 是建筑图。房子里的软装可能有各种风格但建筑图上哪里是承重墙、哪里有窗户、哪里是门标注得一清二楚。你不需要钻到墙体里去摸水电管读图纸就够了。Python 自带ast模块标准的官方库不用装任何第三方依赖。核心函数就几个ast.parse(source)把源码字符串变成 AST 对象。ast.dump(tree, indent2)把 AST 对象转成可读的树形文本调试神器。ast.walk(tree)深度优先遍历整棵树生成所有节点的迭代器。ast.NodeVisitor写一个访问者类按节点类型自动分发。ast.NodeTransformer写一个转换类可以原地修改或删除节点。2.2 用 ast.dump 看清一棵树的真身拿最基础的调试语句df.head()举例在 Python 里跑下面这段import ast code df.head() tree ast.parse(code) print(ast.dump(tree, indent2))输出大概是这个结构Module( body[ Expr( valueCall( funcAttribute( valueName(iddf, ctxLoad()), attrhead, ctxLoad()), args[], keywords[]))], type_ignores[])一个一个拆开看Module是整个文件的根节点相当于一本书的封面。Expr节点表示一个表达式作为独立语句。注意df.head()虽然是一个 Call函数调用但它能成为语句是因为外面包了一层Expr。这一点是整个清理工作最关键的突破口。Call节点表示函数调用func指向被调用的对象args是位置参数keywords是关键字参数。Attribute节点表示属性访问也就是df.head里的.headvalue指向左边的对象dfattr是属性名字符串。Name节点表示一个变量名引用。那么再看print(hello)它的结构类似只是Call.func的类型是Name(idprint)而不是Attribute。所以清理器要做的事情就清楚了找到所有Expr节点看里面是不是Call再判断Call的函数名是否落在目标名单里命中就删。2.3 ast.walk 和 NodeVisitor 怎么用如果你只是想扫描代码用ast.walk(tree)配合isinstance判断节点类型就够了。比如想统计代码里有多少次print调用import ast source print(1)\nvalue foo()\nprint(2) tree ast.parse(source) count 0 for node in ast.walk(tree): if isinstance(node, ast.Call): func node.func if isinstance(func, ast.Name) and func.id print: count 1 print(count) # 2ast.walk是只读的适合做分析。但如果你想修改、删除节点就得用NodeVisitor或NodeTransformer。NodeVisitor同样只读但它的好处是会自动按节点类型分派到对应的方法比如遇到Expr节点就调用visit_Expr遇到Call节点就调用visit_Call代码写起来更清晰。NodeTransformer则在遍历的同时允许你替换或移除节点是手术刀级别的工具。3. 第一版实现用 NodeTransformer 直接改写语法树3.1 三十行代码的核心逻辑先给出第一版思路最直接让NodeTransformer删掉目标节点再把改动后的 AST 重新转换成源代码。import ast # 要清理的目标函数名 TARGET_FUNCS { print, head, show, to_html, display, pprint, } class DebugCleaner(ast.NodeTransformer): def visit_Expr(self, node): # 先递归处理子节点保证嵌套表达式也被访问到 node self.generic_visit(node) # 如果经过处理后 node 已经不在了直接返回 None if node is None: return None # 只处理表达式语句里的函数调用 value node.value if isinstance(value, ast.Call): name self._get_func_name(value.func) if name in TARGET_FUNCS: return None # 返回 None 表示删除这个节点 return node staticmethod def _get_func_name(func): if isinstance(func, ast.Name): return func.id if isinstance(func, ast.Attribute): return func.attr return None def clean_source(source: str) - str: tree ast.parse(source) cleaner DebugCleaner() tree cleaner.visit(tree) ast.fix_missing_locations(tree) return ast.unparse(tree)核心逻辑就三块visit_Expr只处理表达式语句天然避开了赋值语句、函数定义等场景。_get_func_name同时处理了裸调用print(...)和属性调用df.head()取的都是函数名的字符串。在NodeTransformer里return None的意思就是干掉这个节点。ast.fix_missing_locations(tree)这行很多人会漏。删除节点后AST 里某些节点可能缺了lineno等位置信息unparse在某些版本上会报错手动补一下位置信息更稳妥。3.2 跑一个真实样例我用一段接近真实的数据分析脚本来测试import pandas as pd df pd.read_csv(data.csv) print(加载完成共, len(df), 行) df.head() print(df.describe()) result df.groupby(city).mean() result.show() html_body df.to_html()调用清理函数后我期望得到import pandas as pd df pd.read_csv(data.csv) result df.groupby(city).mean() html_body df.to_html()这里有两个值得注意的点df.head()和result.show()被删了因为它们只是表达式语句。html_body df.to_html()保留因为它是Assign节点不是Expr节点。后面可能还有代码用html_body去发邮件或写文件删了就会 NameError。print(加载完成...)和print(df.describe())也删了尽管里面调用了df.describe()但因为describe()没有副作用删除是安全的。第一版在测试用例上表现不错。但实际用起来很快就会发现一个让人皱眉的问题。3.3 第一版暴露出的问题注释和格式全丢ast.unparse负责把 AST 对象变回源码字符串问题是它只保留语义不保留注释、空行、字符串引号风格这些皮相。跑一遍这段测试代码就能看出问题print(① 号测试) # 这是一个调试注释 df.head()清理输出会变成print(①号测试)里的注释没了空行被压缩字符串引号也可能被统一成单引号原本 8 格缩进变成 4 格。这在简单的玩具项目里还行但在真实项目中你的同事大概率不希望.py 文件被格式化工具重排一遍。更麻烦的是如果文件里有# type: ignore、# noqa、# pylint: disable这类带指令性质的注释丢失后 CI 的 lint 和类型检查都可能会报一堆新问题。所以第一版适合一次性脚本、快速清理不适合直接落在团队仓库里。要在真实项目里用我们需要换一个思路AST 负责定位源码层负责删除。4. 更实用的方案AST 定位、源码层删除4.1 为什么要把找和删分成两步想保留注释和格式就不能用ast.unparse重新生成整个文件。更好的做法是用 AST 找到所有需要删除的表达式语句并记录它们的行号范围。回到原始源码按行过滤只删掉这些行。顺带清理删除后留下的多余空行。这样AST 只做它最擅长的事——精确识别代码结构而源码层做最擅长的事——保留原始字节、保留注释、保留格式化样式。相当于大夫负责画手术标记护士按标记动刀互不干扰。4.2 完整的清理脚本下面这段是我实际在用的版本支持多行表达式、支持输出删除报告、支持 dry-run 预览核心函数可以直接集成到你的自动化脚本里。import ast import sys from pathlib import Path # 可以按自己项目情况增删 TARGET_FUNCS { print, head, show, to_html, display, pprint, } class DebugCallFinder(ast.NodeVisitor): 只负责找不负责改。 def __init__(self): self.matched_nodes [] def visit_Expr(self, node): if isinstance(node.value, ast.Call): func node.value.func name None if isinstance(func, ast.Name): name func.id elif isinstance(func, ast.Attribute): name func.attr if name in TARGET_FUNCS: self.matched_nodes.append(node) # 继续往下遍历不要遗漏嵌套表达式 self.generic_visit(node) staticmethod def get_line_range(node): 返回节点占用的起始行和结束行。 start node.lineno # end_lineno 是 Python 3.8 才有的属性做兼容处理 end getattr(node, end_lineno, start) return start, end def remove_debug_lines(source: str, target_funcsNone): 主入口。传入源码字符串返回清理后的源码。 global TARGET_FUNCS if target_funcs is not None: TARGET_FUNCS target_funcs tree ast.parse(source) finder DebugCallFinder() finder.visit(tree) lines source.splitlines(keependsTrue) total_lines len(lines) dead [False] * (total_lines 2) # 多留一位避免越界 deleted_info [] for node in finder.matched_nodes: start, end DebugCallFinder.get_line_range(node) for lineno in range(start, end 1): if 1 lineno total_lines: dead[lineno] True deleted_info.append((lineno, lines[lineno - 1].rstrip())) new_lines [line for idx, line in enumerate(lines, start1) if not dead[idx]] # 压缩连续空行最多保留一个空行 result [] blank_count 0 for line in new_lines: if line.strip() : blank_count 1 if blank_count 1: result.append(line) else: blank_count 0 result.append(line) return .join(result), deleted_info def clean_file(path: Path, dry_run: bool True): source path.read_text(encodingutf-8) cleaned, deleted_info remove_debug_lines(source) if dry_run: print(f {path}预览模式未写入 ) for lineno, content in deleted_info: print(f 第{lineno}行: {content}) return if deleted_info: path.write_text(cleaned, encodingutf-8) print(f{path}: 已删除 {len(deleted_info)} 行) if __name__ __main__: # 用法python cleaner.py --dry-run ./scripts dry_run --dry-run in sys.argv for arg in sys.argv[1:]: if arg --dry-run: continue p Path(arg) if p.is_dir(): for f in p.rglob(*.py): clean_file(f, dry_rundry_run) elif p.is_file(): clean_file(p, dry_rundry_run)关键点有两个get_line_range利用node.end_lineno处理多行表达式。比如print(\n a,\n b\n)这种写法从lineno到end_lineno之间的每一行都要删否则会留下残缺的括号行。dry_run参数非常重要。在真实团队里直接改文件之前一定要先看预览用git diff确认没有误删再真正写入。4.3 效果对比从 Notebook 风格到可交付的干净脚本用一段带注释的代码做对比这是某次清理的真实模板import pandas as pd # 加载数据 df pd.read_csv(sales.csv) print(数据形状:, df.shape) # 临时查看 df.head() # 临时查看 total df[amount].sum() result df.groupby(region).sum() result.show() report result.to_html() # report 后面要写入邮件清理后import pandas as pd # 加载数据 df pd.read_csv(sales.csv) total df[amount].sum() result df.groupby(region).sum() report result.to_html() # report 后面要写入邮件注意# 加载数据、report 后面要写入邮件这些注释都还在空行也保留了一个result.show()消失了df.head()那行连同右侧的临时注释一起消失。这就是源码级删除方案的直观效果。5. 用过几轮之后踩到的边界情况工具能做出来和工具敢上生产中间隔着一堆边界情况。下面这几个坑是我在真实脚本上跑了多轮之后才总结出来的每一类都有对应的防御策略。5.1 print 参数里的函数调用被一起删掉了最隐蔽的问题出现在print(get_data())这种写法。print是目标函数应该删但它的参数get_data()是一个函数调用可能带着副作用。删掉整行等于把get_data()的调用也干掉了。如果get_data()内部有写缓存、更新计数、拉取远程数据这类副作用删除 print 不只是一行输出没了而是整个行为变了。我的处理办法在清理时单独检测被删表达式的参数里是否还有函数调用如果有则输出警告。默认还是删但警告列表里会提示第 12 行 print(get_data()) 包含一个函数调用请人工确认 get_data() 是否可省略。如果你在团队里使用这个消息可以让你在 merge 之前再人工检查一遍。5.2 同名方法误杀head、show、to_html这些名字太通用了。不是只有 DataFrame 有show()自定义的可视化类、微信机器人、游戏引擎场景都可能出现。如果你的代码里有下面这种player.show() # 这是真的在渲染角色删了就出大事 --- logger.show() # 这是某个内部工具的正常调用简单按函数名匹配的后果就是误删。我的初始工具就发生过一次事故清掉了一个同事在数据处理中间调用table.show()的语句而那行是为了把中间表输出到内部查看器后续代码还依赖查看器的交互状态。当时幸好是在dry_run阶段发现的。防御机制有两个方向。第一调用者白名单只有df、data、df_result、spark_df这类看起来像数据对象的变量它身上的head()、show()、to_html()才删。第二方法保护名单在TARGET_FUNCS之外再维护一个PROTECTED_FUNCS遇到render、display_to_user、publish这类名字直接跳过。实际项目里很少只用一个名单多数是全局名单 项目级配置结合。5.3 赋值语句、同一行多语句和对象方法限定前面说html_body df.to_html()能自动保留是因为我们只匹配Expr节点。但还有更麻烦的赋值语句和调用出现在同一行用分号连接。data load_data(); print(done)AST 里这一行会被解析成两个节点一个Assign一个Expr。按行号删除时我们的脚本会把这一整行都删掉data load_data()也跟着没了。这几乎是不可接受的错误。所以我在真实工具里加了一个检查如果某个要删的行上还有其他非目标节点就跳过整行删除把它记到手动处理清单里。同样的道理适用于for i in range(3): print(i)这种单行 for 循环加 print 的写法也不常见但遇到时最好跳过整行不要粗暴删掉for语句。对象方法限定这块我在代码里做了一个简单但实用的扩展区分全局函数和属性方法。print、display、pprint这类全局函数只要名字匹配就删head、show、to_html这类属性方法默认也删但可以通过ignore_objects配置一个变量名黑名单比如ignore_objects{player, logger, viewer}匹配到这些对象就跳过。5.4 给工具加保护机制dry-run、保留标记和警告列表在跑真正的清理之前我强烈建议做两件小事第一dry-run 是底线。上面的脚本里默认就是 dry-run只有显式传入--write才会真正写文件。配合git diff检查能防住 90% 的误删。第二给代码加保留标记。我习惯在工具里内置一个简单的规则如果目标行内包含# keep这样的标记就跳过删除。比如print(df.shape) # keep 这行后面要配合手动验证留着这样既享受了自动清理的便利又给确实要保留的调试行留了逃生通道。标记词可以按团队习惯自己定只要不跟正常注释冲突就行。除了保留标记我还会把每次清理的删除清单统一输出到一个日志文件里包括行号、原文、所属文件。这样万一后面发现问题可以快速对比删除前和删除后的差异而不是靠记忆去猜。6. 把这套思路往外扩一步6.1 从删调试行到代码体检报告清理工具做出来之后我顺带在它上面加了一个统计模式。因为DebugCallFinder已经把所有的目标节点都找出来了顺手记录每个文件里各种调试语句的数量就能生成一份简单的代码体检报告文件里有多少个print、多少个head()、多少个show()。这些调试语句分布在哪些行。有没有print(get_data())这类包含副作用调用的高危模式。有没有定义了下游变量但从未使用的疑似残留。我把这些输出成一个 CSV 或 Markdown 表格在团队里跑一遍看着报告做删减决策比盲目全局替换踏实得多。尤其当你需要清理一批历史遗留脚本的时候这个报告就是你跟同事沟通的证据。6.2 更多可以用 AST 自动化的场景AST 的用处远不止删调试语句。用同一套AST 定位 源码级修改的组合拳我做过几个非常实用的自动化工具未使用 import 清理遍历所有Import/ImportFrom节点再统计全文件里Name节点的引用找出导入了但从未使用过的模块。这在整理长期演进的脚本时效果显著比 pylint 的提示更可控。TODO / FIXME 清单虽然注释不在 AST 里但可以通过行号映射去关联输出这个 TODO 在哪个函数里的报告。函数复杂度简表遍历每个FunctionDef节点统计函数内部有多少if、for、while、with分支生成圈复杂度的粗略估算找出需要重构的长函数。危险调用扫描找出所有eval、exec、os.system、subprocess调用标出它们的行号和上下文方便安全审计。批量 API 迁移比如某个旧库的方法df.compute()迁移到df.execute()用 AST 精确替换Call.func的Attribute.attr同时保留注释和格式比正则替换安全得多。这些场景的共同点都是结构敏感。只要你想做的是读懂代码结构再动手AST 就是比字符串处理更可靠的基础设施。6.3 关于 AST 工具链的几点总结从一个简单的删 print需求出发最后收获的是一整套基于 AST 的代码自动化处理能力。我个人的几点体会能用 AST 解决的问题尽量不要写复杂的正则。正则适合处理没有结构的文本而代码是有结构的。修改类工具优先选择AST 定位 源码层删除的架构避免unparse带来的格式和注释丢失。分析类工具则直接用ast.walk或NodeVisitor就够了。任何自动化删除工具都必须有预览模式和保护机制。代码删除是高风险操作宁可多一道确认也不要让工具在无人值守时误伤。工具的目标名单一定要可配置。不同项目的调试残留风格差异很大数据分析项目普遍是head()和to_html()后台服务项目里print可能反而不是调试而是正式日志。真正好用的工具是允许每个人按自己的项目情况去定义规则而不是把一份固定名单写死。如果你也想做类似的事情我的建议是从一个很小的场景开始比如先只处理print在真实项目里跑通一个文件加上 dry-run 和 diff 检查再逐步扩展到head、show、to_html。等这套流程稳定了你会发现给代码做手术这件事不再需要靠肉眼一行行盯到眼花而是可以像流水线一样精准、可回退、还能出报告。至少对我来说从那批写满调试残留的脚本到现在每次清理完再看一遍git diff的感觉确实比手工删两三小时舒服太多了。