vlambda博客
学习文章列表

用于修补代码和评估代码质量的抽象语法树

作者 | Abdul Qadir
译者 | 张健欣
策划 | 田晓旭
我们如何轻松地大规模地修补 100,000 行代码?通过阅读本文,了解我们如何使用一个简单但强大的数据结构——抽象语法树(Abstract Syntax Tree, AST)来创建一个系统,从单个中心点映射源代码依赖项,然后修补所有依赖项。

一个软件系统通常是围绕如何编写依赖项(例如底层语言系统、框架、库等等)而构建的。这些依赖项的变动可能会对软件系统本身造成连锁反应。例如,最近,著名的 Python 库 pandas 发布了其 1.0.0 版本,该版本弃用并更改了其先前 0.25.x 版本中的一些功能。一个组织可能有许多系统使用 0.25.x 版本的 pandas。因此,将其升级到 1.0.0 需要每个系统的开发人员仔细阅读 pandas 的变更文档并相应地修补他们的代码。

由于我们开发人员喜欢将繁琐的任务自动化,所以我们自然会考虑编写一个补丁脚本,根据新的

pandas 版本中的变动升级所有系统的源代码。补丁脚本可以解析源代码并执行某些查找 + 替换操作。但是这样的脚本可能是不可靠也不全面的。例如,假设补丁脚本需要将一个函数的名字从 get 改为 create,包括任何其被调用的地方。简单的查找 + 替换操作会替换单词“get”,即使它不是一个函数调用。另外一个例子是,查找 + 替换操作不能处理代码语句溢出为多行的情况。我们需要补丁脚本解析源代码,同时理解语言结构。在本文中,我们建议使用抽象语法树(Abstract Syntax Trees,AST)来写这些补丁脚本。稍后,我们将介绍如何使用 AST 来评估代码质量。

1抽象语法树 (AST)

抽象语法树(Abstract Syntax Tree,或 AST)是源代码的一种树形展示。

几乎每种语言都有一种方法根据代码生成 AST。我们使用 Python 来构建我们的系统的一些关键部分。因此,本文使用 Python 来给出示例和亮点,但是这些知识也可以应用到任何其它语言。

Python 有一个名为 ast 的包来生成 ASTs。这里有一个关于它的小教程。

代码:

import ast# Simple code that sets a variable var to 1 and then prints it.code = """var = 1print(var)"""# Converts code to AST. Object 'head' points to the head of the AST.head = ast.parse(code)print(head)

输出:

<_ast.Module object at ...>

所以,AST 的头是一个 Module 对象。让我们深入研究。这个 ast 包提供了一个 ast.dump(node) 函数,该函数返回以这个节点为根节点的整个树的格式化视图。我们在 head 对象上调用这个函数,看看我们能得到什么。

代码:

print(ast.dump(head))

输出 (美化过):

Module( body=[ Assign( targets=[Name(id='var', ctx=Store())], value=Num(n=1) ), Expr( value=Call(func=Name(id='print', ctx=Load()), args=[Name(id='var', ctx=Load())], keywords=[]) ) ])

查看 ast.dump 的输出,我们可以看到类型为 Module 的 head 对象有一个 body 属性,其值是一个包含 2 个节点的列表——一个表示 var = 1,另一个表示 print(var)。第一个表示 var = 1 的节点有一个 target 属性表示 LHSvar,一个 value 属性表示 RHS1。让我们看看能不能打印这个 RHS。

代码:

print(head.body[0].value.n)

输出:

1

所以,它如预期生效。现在,我们尝试将 RHS 的值从 1 修改为 2。

代码:

head.body[0].value.n = 2print(ast.dump(head))

输出 (美化过):

Module( body=[ Assign( targets=[Name(id='var', ctx=Store())], value=Num(n=2) ), Expr( value=Call(func=Name(id='print', ctx=Load()), args=[Name(id='var', ctx=Load())], keywords=[]) ) ])

我们可以看到相应属性的值已经改为 2。现在,我们想要将 AST 转换回代码来获得修改后的代码。为此,我们使用了一个名为 astunparse 的 Python 包,因为 ast 没有提供这个功能。

代码:

import astunparseprint(astunparse.unparse(head))

输出:

var = 2print(var)

因此,修改后的代码的语句预期为 var = 2 而不是 var = 1。

2智能补丁

既然我们已经理解了 ASTs,以及如何生成 AST、检查 AST、修改 AST 并根据 AST 重新生成代码,让我们回到编写补丁脚本的问题上来,将系统代码修改为使用 pandas1.0.0 而不是 pandas0.25.x。我们称这些基于 AST 的补丁脚本为“智能补丁(IntelliPatch)”。

pandas1.0.0 中的所有向后兼容性都列在这个页面。让我们以列表中的第一个向后兼容性为例来写这种智能补丁。

避免使用 MultiIndex.levels 的名字

在 pandas1.0.0 中,一个 MultiIndexlevel 的名字不能使用 = 更新,而是需要使用 Index.set_names()。

使用 pandas 0.25.x 的代码:

import pandas as pdmi = pd.MultiIndex.from_product([[1, 2], ['a', 'b']], names=['x', 'y'])print(mi.levels[0].name)mi.levels[0].name = "new name"print(mi.levels[0].name)

输出:

x new name 

上述代码在 pandas1.0.0 中会产生一个 RunTimeError。为了使用 pandas1.0.0,它要修改为如下代码。

等价于使用 pandas 1.0.0 的代码:

import pandas as pd mi = pd.MultiIndex.from_product([[1, 2], ['a', 'b']], names=['x', 'y']) print(mi.levels[0].name) mi = mi.set_names("new name", level=0) print(mi.levels[0].name) 

IntelliPatch 需要执行以下操作:

  1. 创建给定代码的 AST 并遍历它。

  2. 找出任何表示.levels[ ].name = 形式代码的所有节点。

  3. 将第二步找到的所有节点替换为=.set_names( , level= ) 形式代码的节点。

下面是这样做的 IntelliPatch 脚本。

intelli_patch.py

import astdef is_multi_index_rename_node(node): """ Checks if the given node represents the code: <var>.levels[<idx>].name = <val> and returns the corresponding var, idx and val if it does. """ try: if ( isinstance(node, ast.Assign) and node.targets[0].attr == "name" and node.targets[0].value.value.attr == "levels" ): var = node.targets[0].value.value.value.id idx = node.targets[0].value.slice.value.n val = node.value return True, var, idx, val except: pass return False, None, None, Nonedef get_new_multi_index_rename_node(var, idx, val): """ Returns AST node that represents the code: <var> = <var>.set_names(<val>, level=<idx>) for the given var, idx and val. """ return ast.Assign( targets=[ast.Name(id=var)], value=ast.Call( func=ast.Attribute(value=ast.Name(id=var), attr="set_names"), args=[val], keywords=[ast.keyword(arg="level", value=ast.Num(n=idx))], ), )def patch(node): """ Takes an AST rooted at the give node and patches it. """ # If it is a leaf node, then no patching needed. if not hasattr(node, "_fields"): return node # For every child of the node, modify it if needed and recursively call patch on it. for (name, field) in ast.iter_fields(node): if isinstance(field, list): for i in range(len(field)): check, var, idx, val = is_multi_index_rename_node(field[i]) if check: field[i] = get_new_multi_index_rename_node(var, idx, val) else: patch(field[i]) else: check, var, idx, val = is_multi_index_rename_node(field) if check: setattr(node, name, get_new_multi_index_rename_node(var, idx, val)) else: patch(field)
用法示例 1:
from intelli_patch import patchimport astimport astunparsecode = """import pandas as pdmi = pd.MultiIndex.from_product([[1, 2], ['a', 'b']], names=['x', 'y'])mi.levels[0].name = "new name""""head = ast.parse(code)patch(head)print(astunparse.unparse(head))

输出:

import pandas as pdmi = pd.MultiIndex.from_product([[1, 2], ['a', 'b']], names=['x', 'y'])mi = mi.set_names('new name', level=0)
用法示例 2:
from intelli_patch import patchimport astimport astunparsecode = """import pandas as pdclass C(): def f(): def g(): mi.levels[ 0 ].name = "new name"mi = pd.MultiIndex.from_product([[1, 2], ['a', 'b']], names=['x', 'y'])"""head = ast.parse(code)patch(head)print(astunparse.unparse(head))

输出:

import pandas as pdclass C(): def f(): def g(): mi = mi.set_names('new name', level=0)mi = pd.MultiIndex.from_product([[12], ['a''b']], names=['x''y'])
在用法示例 2 中,请注意,要被替换的代码语句多于 1 行,并且出现在类 C的函数 f 中的函数 g 中。IntelliPatch 也能处理这种情况。

可以扩展补丁脚本来处理 pandas1.0.0 中的所有向后兼容性。然后编写一个外部函数,遍历系统中的每一个 Python 文件,读取其代码,对其进行修补,然后写回到磁盘。值得注意的是,开发人员应该在提交 IntelliPatch 所做的更改前对其进行检查。例如,如果代码托管在 git 上,那么开发者应该执行一个 git diff 命令并进行检查。

影响

在 Soroco,我们目前已经编写了 5 个 IntelliPatch 脚本,它们运行在 10 个系统上。每个脚本成功解析和修补了 10 个系统中的大约 150,000 行代码。就生产率而言,这项工作花费我们的一位工程师整整三天来完成。这位工程师在实现这些方案前学习了关于 AST 的知识。

在这 5 个脚本中,有一个脚本是独一无二的——一个代码清理器,而且不是一个传统的补丁。这一需求源于一个外部团体试图审查代码的大纲,而不用分享实际的逻辑和代码细节。因此,我们编写了一个清理器,它可以清理代码中的逻辑和其它关键元素,同时只保留导入、类和函数定义、文档字符、类型注解和审查所需的一些非常具体的信息。因此,AST 对于构建一个代码清理器也是一个有价值的工具。

局限性

使用 Python 的 ast 包修补代码的一个问题是,它丢失了原始源代码的所有格式和注释。这可以通过使补丁更智能一点来解决。我们可以让它只解析修改过的节点,并在文件中相应的行号插入修改过的代码,而不是解析整个修补过的 AST 并将其写入磁盘。这些 ast 节点有一个 lineno 属性可以用来获取文件中要注入的修补过的代码的行号。

3代码质量评估

现在我们已经知道 AST 在编写智能补丁脚本时非常有用,在本章节,我们将解释它如何用来评估代码质量。许多 IDE 和代码检查器,例如 PyCharm 和 SonarQube,使用 AST 来执行代码质量检查。我们可以使用 AST 来根据我们的需求创建我们自己的代码质量检查。下面是一些例子:

示例 1: 非自解释变量名

你想要你组织中的开发者在代码中使用良好的自解释的变量名。你在代码中看到的最常见的问题是使用单字符变量名,例如 i、j 等。下面是一个可以检查这一点的脚本。

variable_name_check.py

import astdef check(node): """ Takes an AST rooted at the given node and checks if there are single character variable names. """ # If it is a leaf node, then return. if not hasattr(node, "_fields"): return # For every child of the node, check if it is a variable having single character # name and recursively call check on it. for child_node in ast.iter_child_nodes(node): if isinstance(child_node, ast.Name) and len(child_node.id) == 1: print( f"Single character name '{child_node.id}' used at line number {child_node.lineno}" ) check(child_node)

用例:

from variable_name_check import checkimport astcode = """a = 1b = aprint(b)"""head = ast.parse(code)check(head)

输出:

Single character name 'a' used at line number 2Single character name 'b' used at line number 3Single character name 'a' used at line number 3Single character name 'b' used at line number 4
示例 2: 未记录日志的 except 代码块

你想要你组织中的人员确保在捕获到异常时进行日志记录。你希望从每个 except 代码块调用日志模块的 error 或 exception 函数。下面是一个使用 AST 检查这一点的脚本。

unlogged_except_check.py

import astdef check(node): """ Takes an AST rooted at the given node and checks if there are un-logged except code blocks. """ # If it is a leaf node, then return. if not hasattr(node, "_fields"): return # For every child of the node, check if it is un-logged except code block # and recursively call check on it. for child_node in ast.iter_child_nodes(node): if isinstance(child_node, ast.ExceptHandler) and not is_logging_present( child_node ): print( f"Neither 'error' nor 'exception' logging is present within the except block starting at line number {child_node.lineno}" ) check(child_node)def is_logging_present(node): """ Takes an AST rooted at the given node and checks whether there is an 'error' or 'exception' logging present in it. """ # If it is a leaf node, then return False. if not hasattr(node, "_fields"): return False # If it represents an `error` or `exception`function call then return True. if ( isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in ["error", "exception"] ): return True # Recursively checking if logging is present in the children nodes. for child_node in ast.iter_child_nodes(node): if is_logging_present(child_node): return True return False

用例:

from unlogged_except_check import checkimport astcode="""try: passexcept ValueError: logging.error("Error occurred") try: pass except KeyError: logging.exception("Exception handled") except NameError: passtry: passexcept: logging.info("Info level logging")"""head = ast.parse(code)check(head)
输出:
Neither 'error' nor 'exception' logging is present within the except block starting at line number 14Neither 'error' nor 'exception' logging is present within the except block starting at line number 19

如果发现一个 except 代码块没有任何日志记录,可以采取进一步行动,代码质量检查器可以通过在 AST 中增加一个相应的节点来在代码中插入日志。

结论

AST 的用途远远超过了本文的讨论范围。例如,给定系统中的文件的 AST 可以用来创建一个调用图。在运行时期间创建的调用图可能不会覆盖所有的代码路径。但是,使用 AST 静态创建的调用图会覆盖所有的代码路径,因此将是全面的。然后这个调用图可以用来创建一份人类可读的系统文档。我们在 Soroco 创建了这样一个功能,我们称为“LiveDoc”,我们可以改天在另外一篇文章中谈谈这个话题。

原文链接

https://engineering.soroco.com/abstract-syntax-tree-for-patching-code-and-assessing-code-quality/?fileGuid=WvJYqYGvPXdx3dQt

点个在看少个 bug 👇