Odoo 中引用的 AST 是什么

在阅读 Odoo 的后台 Python 代码中,经常看到 AST 相关的代码,每次遇到这些代码都不自主跳过;但就在昨天在 Conda Python 3.8 的新环境中,Odoo 12 竟然没有跑起来,抛出的异常在 AST 相关的位置上。

odoo.addons.base.models.qweb.QWebException: required field "posonlyargs" missing from arguments
Traceback (most recent call last):
File "odoo/odoo/addons/base/models/qweb.py", line 332, in compile
unsafe_eval(compile(astmod, '<template>', 'exec'), ns)
TypeError: required field "posonlyargs" missing from arguments

Error when compiling AST
TypeError: required field "posonlyargs" missing from arguments
Template: 173
Path: /templates/t/t/form/input[2]
Node: <input type="hidden" name="redirect" t-att-value="redirect"/>

从异常的信息来看是 AST 在处理

Node: <input type="hidden" name="redirect" t-att-value="redirect"/>

这个 Node 的时候出了问题。狠了狠心借着这个机会看看 Python AST 究竟是啥。

什么是 AST

AST 即抽象语法树,百度一个定义:

在计算机科学中,抽象语法树Abstract Syntax Tree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节。比如,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现;而类似于 if-condition-then 这样的条件跳转语句,可以使用带有两个分支的节点来表示。

Python 官方文档 — Abstract Syntax Trees

AST 模块可帮助 Python 应用程序处理 Python 抽象语法语法的树。 每个Python 版本都可能更改抽象语法。 该模块有助于以编程方式找出当前语法的外观。
通过将ast.PyCF_ONLY_AST作为标志传递给compile()内置函数,或使用此模块中提供的parse()帮助器,可以生成抽象语法树。 结果将是一棵对象树,其所有类均继承自ast.AST。 可以使用内置的compile()函数将抽象语法树编译为Python代码对象。

Odoo 借助 Python AST 在抽象语法树的层次动态构造 Python 程序,从而能够将 QWeb Template 中的语法映射成 Python 代码。通过直接构造 AST,然后再 Compile,再求值。从而能够在 QWeb 中使用 Python 的语法,在模板文件中直接使用Python代码逻辑。

AST 中的 Node 递归起来就是一个 Tree,每个节点就是 Node;ast.parse(code_string) 能把 Python 代码分析成 AST,Python 本身没有提供把 AST 变成 Python 代码的功能,有一些第三方库可以做这个事情。举个简单的例子:

>>> import ast
>>> ast.dump(ast.parse('-5'))
'Module(body=[Expr(value=UnaryOp(op=USub(), operand=Constant(value=5, kind=None)))], type_ignores=[])'
>>> 

上面的例子是给 ‘-5’ 这个常数构造 AST,其body 是一个 Expr,value 是一个 UnaryOp,operand 是 Constant。

再看一个:

>>> ast.dump(ast.parse('print("Hello World")'))
"Module(body=[Expr(value=Call(func=Name(id='print', ctx=Load()), args=[Constant(value='Hello World', kind=None)], keywords=[]))], type_ignores=[])"
>>> 

这个Expr 就是一个 Call,Call 需要的参数是 func,args。

>>> node = ast.UnaryOp()
>>> node.op = ast.USub()
>>> node.operand = ast.Constant()
>>> node.operand.value = 5
>>> node.operand.lineno = 0
>>> node.operand.col_offset = 0
>>> node.lineno = 0
>>> node.col_offset = 0
>>> 
>>> ast.dump(node)
'UnaryOp(op=USub(), operand=Constant(value=5))'
>>> 

上面就是手动构造一个 ast node。同样再看一个例子:

>>> x = """
... def p(s):
...     print(s)
... """
>>> x
'\ndef p(s):\n\tprint(s)\n'
>>> ast.dump(ast.parse(x))
"Module(body=[FunctionDef(name='p', args=arguments(posonlyargs=[], args=[arg(arg='s', annotation=None, type_comment=None)], vararg=None, kwonlyargs=[], kw_defaults=[], kwarg=None, defaults=[]), body=[Expr(value=Call(func=Name(id='print', ctx=Load()), args=[Name(id='s', ctx=Load())], keywords=[]))], decorator_list=[], returns=None, type_comment=None)], type_ignores=[])"
>>> 

这个例子是定义一个函数 FunctionDef,能看出来 AST 如何描述定义函数。

再回到 Odoo 的 qweb.py 都有对应的函数,如根据 QWeb 模板生成函数定义 FunctionDef:

    def _create_def(self, options, body, prefix='fn', lineno=None):
        """ Generate (and globally store) a rendering function definition AST
        and return its name. The function takes parameters ``self``, ``append``,
        ``values``, ``options``, and ``log``. If ``body`` is empty, the function
        simply returns ``None``.
        """
        #assert body, "To create a compiled function 'body' ast list can't be empty"

        name = self._make_name(prefix)

        # def $name(self, append, values, options, log)
        fn = ast.FunctionDef(
            name=name,
            args=arguments(args=[
                arg(arg='self', annotation=None),
                arg(arg='append', annotation=None),
                arg(arg='values', annotation=None),
                arg(arg='options', annotation=None),
                arg(arg='log', annotation=None),
            ], defaults=[], vararg=None, kwarg=None, kwonlyargs=[], kw_defaults=[]),
            body=body or [ast.Return()],
            decorator_list=[])
        if lineno is not None:
            fn.lineno = lineno

        options['ast_calls'].append(fn)

        return name

有没有点感觉,少了点什么, arguments 中少了 posonlyargs,就是这里引起的异常,因为 Python 3.8 的 AST 中需要这个 posonlyargs,而 Odoo 在构造的时候没有指定。

AST Node

Python 的 AST 的 Node 有很多,可以分成 Literal (字面量),Variable(变量),Expression (表达式),Statement (语句),Control Flow(控制流),Function and Class (函数和类定义),Async await(异步)。

Literal Node

如一个数,True False, 字符串用 Constant 表示,Bytes,List,Tuple,Dict;一个 Dict 在代码中可以写成 {"a":1,**d},在 AST 中就是 ast.Dict。

>>> ast.dump(ast.parse('{"a":1, **d}'))
"Module(body=[Expr(value=Dict(keys=[Constant(value='a', kind=None), None], values=[Constant(value=1, kind=None), Name(id='d', ctx=Load())]))], type_ignores=[])"

写段简单的 Python Code,parse之后再把 Tree dump 出来;用这种方法很容易找到代码对应的 AST Node Class 是什么。

Variable Node

Name 用来表达变量的名字,id 表示以字符串表示的变量名,ctx可以是 Store,Load,Del,就是存储,加载,删除。看看对应的代码很容易理解:

>>> ast.dump(ast.parse("a"))
"Module(body=[Expr(value=Name(id='a', ctx=Load()))], type_ignores=[])"
>>> ast.dump(ast.parse("a = 2"))
"Module(body=[Assign(targets=[Name(id='a', ctx=Store())], value=Constant(value=2, kind=None), type_comment=None)], type_ignores=[])"
>>> ast.dump(ast.parse("del a"))
"Module(body=[Delete(targets=[Name(id='a', ctx=Del())])], type_ignores=[])"
>>> 

所有的代码都是在 Python 的交互模式下运行,目前版本是 Python 3.8.0。

Python 3.8.0 (default, Nov  6 2019, 21:49:08) 
[GCC 7.3.0] :: Anaconda, Inc. on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import ast
>>> 

Expression (表达式)

Expr 类来表示一个表达式,前面已经看到好多 Expr 了。它更像一个容器。

当表达式(例如函数调用)本身作为语句(表达式语句)出现而未使用或存储其返回值时,它将包装在此容器中。 比如 -a,f(),a > 0 等等都是表达式。-a 已经在前文中提到了是用 Unary 来表示,再写个简单的:

>>> ast.dump(ast.parse("0 < a < 10"))
"Module(body=[Expr(value=Compare(left=Constant(value=0, kind=None), ops=[Lt(), Lt()], comparators=[Name(id='a', ctx=Load()), Constant(value=10, kind=None)]))], type_ignores=[])"
>>> 

Expr 中的参数 value,可以是 Compare,Eq,NotEq 之类比较大小,位操作,取成员变量,函数调用等等。

>>> ast.dump(ast.parse("print('Hello World')"))
"Module(body=[Expr(value=Call(func=Name(id='print', ctx=Load()), args=[Constant(value='Hello World', kind=None)], keywords=[]))], type_ignores=[])"
>>> 

Statement (语句)

赋值语句是最基础的了:

>>> ast.dump(ast.parse("b = 1"))
"Module(body=[Assign(targets=[Name(id='b', ctx=Store())], value=Constant(value=1, kind=None), type_comment=None)], type_ignores=[])"

还有很多赋值语句,比如 a += 1 是 Augmented assignment,扩充赋值。

raise assert del pass 这些语句都专门对应 AST 的节点类 Raise,Assert,Delete,Pass。还有专门的导入类 对应 import 语句。

Control Flow (控制流)

就是代码块,也是结构化程序的基础了。if else for break continue try catch 等等都有直接对应的 Node Class。

写个小李子:

>>> x = """
... for a in b:
...     if a > 1:
...             continue
...     else:
...             break
... """
>>> ast.dump(ast.parse(x))
"Module(body=[For(target=Name(id='a', ctx=Store()), iter=Name(id='b', ctx=Load()), body=[If(test=Compare(left=Name(id='a', ctx=Load()), ops=[Gt()], comparators=[Constant(value=1, kind=None)]), body=[Continue()], orelse=[Break()])], orelse=[], type_comment=None)], type_ignores=[])"
>>> 

Function 和 Class def

使用 FunctionDef 节点类。需要下面几个参数。

  • name is a raw string of the function name.
  • args is a arguments node.
  • body is the list of nodes inside the function.
  • decorator_list is the list of decorators to be applied, stored outermost first (i.e. the first in the list will be applied last).
  • returns is the return annotation (Python 3 only).

还是直接写个小李子:

>>> x = """
... def pp(s):
...     print(s)
...     return 0
... """
>>> ast.dump(ast.parse(x))
"Module(body=[FunctionDef(name='pp', args=arguments(posonlyargs=[], args=[arg(arg='s', annotation=None, type_comment=None)], vararg=None, kwonlyargs=[], kw_defaults=[], kwarg=None, defaults=[]), body=[Expr(value=Call(func=Name(id='print', ctx=Load()), args=[Name(id='s', ctx=Load())], keywords=[])), Return(value=Constant(value=0, kind=None))], decorator_list=[], returns=None, type_comment=None)], type_ignores=[])"
>>> 

Odoo 12 中不能适应 Python 3.8 就是构造 AST 有问题。当然还有 Lamba 和 Class 的构造方法,不一一列举。

Async 和 Await 异步支持

还是直接上个例子吧:

>>> x = """
... async def aa():
...     await bb()
... """
>>> ast.dump(ast.parse(x))
"Module(body=[AsyncFunctionDef(name='aa', args=arguments(posonlyargs=[], args=[], vararg=None, kwonlyargs=[], kw_defaults=[], kwarg=None, defaults=[]), body=[Expr(value=Await(value=Call(func=Name(id='bb', ctx=Load()), args=[], keywords=[])))], decorator_list=[], returns=None, type_comment=None)], type_ignores=[])"
>>> 

可见是一个独特的 AsyncFunctionDef 来处理的。

遍历或者修改 AST Tree

面对一个已经存在 Tree,遍历它或者修改它或者显示它。前文中我们大量的使用 dump,就是能够以文本形式显示这个 Tree。

遍历的方法是继承 ast 的 NodeVisitor 或者直接使用它。

class FuncLister(ast.NodeVisitor):
    def visit_FunctionDef(self, node):
        print(node.name)
        self.generic_visit(node)

FuncLister().visit(tree)

上面 overload 了 FunctionDef 的visit,同理可以重载其他的 Node。但是别忘了使用 generic_visit() 来执行基本的 visit 过程。

for node in ast.walk(tree):
    if isinstance(node, ast.FunctionDef):
        print(node.name)

比较直观的方法直接 walk 整个 Tree,然后挑出你感兴趣的部分。

如果你想修改 Tree,可以用 NodeTransformer。

class RewriteName(ast.NodeTransformer):

    def visit_Name(self, node):
        return ast.copy_location(ast.Subscript(
            value=ast.Name(id='data', ctx=ast.Load()),
            slice=ast.Index(value=ast.Str(s=node.id)),
            ctx=node.ctx
        ), node)

tree = RewriteName().visit(tree)

编译执行 AST

AST 构造了程序,可以编译,然后执行。还是直接上例子:

>>> ast.dump(ast.parse("print('Hello World')"))
"Module(body=[Expr(value=Call(func=Name(id='print', ctx=Load()), args=[Constant(value='Hello World', kind=None)], keywords=[]))], type_ignores=[])"
>>> am = ast.parse("print('Hello World')")
>>> exec(compile(am,'<string>','exec'))
Hello World
>>> 

exec 和 compile 都是 Python 的内置语句。

AST 用在何处

许多自动化测试工具,代码覆盖率工具依靠抽象语法树的功能来解析源代码,并发现代码中可能存在的缺陷和错误。 除此之外,AST还用于:

  • 使IDE变得智能并使其成为众所周知的功能。
  • 像Pylint这样的工具使用AST执行静态代码分析
  • 自定义Python解释器

最重要的是 Odoo 引用了,通过简单了解 Python 的 AST,就不用担心看到 ast 相关的代码了。

本文参考引用了:

百度百科的抽象语法树定义
Python 官方网站对 AST 的描述
Green Tree Snakes - the missing Python AST docs
Python AST – Abstract Syntax Tree

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。