PPysolate SpineREAD THE CODE · UNDERSTAND THE SYSTEM
课程与章节
CODE READING / 15

AST 改写的单元测试

guest/test_plm.py

本章目录

本章回答:不启动 Wasm 的快速 Python 单测,究竟能证明 PLM 的哪一部分?答案是 AST 变换后的执行顺序、依赖/分支边界、参数错误位置和 helper 命名;它不能证明 Go Future、C ABI 或取消收尾。

测试直接导入当前 guest/plm.py 的 transform,用 compile/exec 执行返回的 AST。lookup、prepare 和 resolve 都是测试替身:它们记录事件并返回固定值。测试 Manifest 使用当前具名接口,即 [{"name": "lookup", "allow_early_read": True}];它不是旧的工具列表加单独字符串参数。

读事件时记住一个关键区分:prepare 记录的是“thunk 已在 Guest 测试线程中被读取并提交”,不是真正的 Go worker 已启动;resolve 返回的是替身直接提供的值,也不经过 Go map。真实跨边界语义由 plm-tests.md 补上。

source 标记覆盖 guest/test_plm.py 的全部 65 行。下面按测试夹具、语义用例和证据边界说明源码。

1. execute:让每个 AST 结果进入一个可观察的小环境

先建立最小的执行环境。它必须有输入、具名工具和两个 helper,这样测试的是实际 transform 输出,而不是手写的预期树。

guest/test_plm.py · L1–L30 · 打开完整源码

"""Only tests the AST pass here; execution acceptance also uses real Wasm."""
import ast
import unittest
from plm import transform


class PassTests(unittest.TestCase):
    def execute(self, source, inputs=None):
        events = []
        values = {"book": 21, "shipping": 5}
        manifest = [{"name": "lookup", "allow_early_read": True}]
        def lookup(**args):
            events.append(("call", args["key"]))
            return values[args["key"]]
        def prepare(thunk):
            try:
                name, args = thunk()
            except Exception:
                return None
            events.append(("prepare", args["key"]))
            return name, args
        def resolve(token, name, **args):
            events.append(("resolve", args["key"]))
            return values[args["key"]]
        tree, helpers = transform(source, manifest)
        scope = {"inputs": inputs or {}, "lookup": lookup}
        if helpers:
            scope.update(zip(helpers, (prepare, resolve)))
        exec(compile(tree, "<test>", "exec"), scope)
        return scope, events, ast.unparse(tree), helpers

模块文档字符串先声明证据范围:这里只测 AST pass,真实 Wasm 接线另由 Go 测试验收。导入 ast 是为了比较结构和观察 unparse 文本,unittest 提供测试框架,transform 则来自仓库当前实现。

PassTests.execute 创建事件列表和值表:book 为 21,shipping 为 5。lookup 替身记录普通调用事件后按 key 返回值;它模拟的是 bootstrap 中普通工具 proxy 的最终效果,不模拟 JSON bridge。

prepare 替身调用 thunk,把返回的工具名和参数字典拆出来。若 thunk 在读取参数时抛异常,就返回 None,模拟生产 prepare_call 返回 0 的“没有成功准备”;它不记录 prepare 事件,因此之后可以观察原调用是否仍会在 Python 位置报错。正常时记录 prepare 事件,并用 (name,args) 作为本地 token。

resolve 记录 resolve 事件,再按 key 返回固定值。它故意不检查 token,因为这组单测要隔离 AST 插入位置;真实实现中 handle 的未知、失配和单次消费由 Go future.go 检查。

最后,execute 调实际 transform(source, manifest),把 inputs 和具名 lookup 放入 scope;若有 helper,就把生成的两个名字映射到替身。compile/exec 执行变换后的树,并返回 scope、事件、unparse 文本和 helper 名。由此,事件顺序来自执行结果和断言,而不是从 AST 外观猜测。

2. 独立调用:两个 prepare 先发生,两个 resolve 仍按原序

第一个用例构造两个相邻、互不依赖的工具赋值,再用 Python 计算结果。它要同时观察“启动阶段前置”和“交付位置不变”。

guest/test_plm.py · L32–L35 · 打开完整源码

    def test_independent_calls_start_first(self):
        scope, events, _, _ = self.execute('a = lookup(key="book")\nb = lookup(key="shipping")\nresult = a+b')
        self.assertEqual(events, [("prepare", "book"), ("prepare", "shipping"), ("resolve", "book"), ("resolve", "shipping")])
        self.assertEqual(scope["result"], 26)

execute 接收实际源码字符串,随后断言事件严格为:prepare book、prepare shipping、resolve book、resolve shipping。若 pass 把第二个调用的 resolve 也搬到前面,或仍然按原调用同步执行 prepare,这个事件序列都会改变。

最终 result 为 26,说明两个 resolve 返回的值仍绑定到原来的 a、b,最后的 Python 表达式没有改成由测试 helper 直接计算。这个测试能证明 AST 层的相对顺序;它不能证明真实 Go worker 并发。

3. 依赖:prepare 可以后移,但赋值和参数表达式不能被伪造

第二个用例先测试一个普通数据变量作为参数,再直接检查一个工具结果依赖另一个工具结果的改写树。

guest/test_plm.py · L37–L42 · 打开完整源码

    def test_dependency_stays_after_definition(self):
        scope, events, _, _ = self.execute('key = "book"\na = lookup(key=key)\nb = lookup(key="shipping")')
        self.assertEqual(events[0], ("prepare", "book"))
        tree, _ = transform('a = lookup(key="book")\nb = lookup(key=a)', [{"name": "lookup", "allow_early_read": True}])
        self.assertEqual(tree.body[1].targets[0].id, "a")
        self.assertEqual(tree.body[2].value.args[0].body.elts[1].values[0].id, "a")

key = "book" 位于工具组之前。第一个执行检查要求事件以 prepare book 开始,说明 pass 可以保留普通数据赋值,同时在进入工具组时读取当前变量。

随后重新只调用 transform,输入是 a=lookup(key="book") 后接 b=lookup(key=a)。断言 tree.body[1] 的赋值目标仍为 a,也就是第一个原工具赋值没有被 prepare 语句取代;另一个断言沿着第二个 prepare 的 lambda 参数树向下检查,确认它仍读取名字 a。

这两个结构断言比只检查最后结果更有价值:它们能发现一种错误实现——把依赖值提前复制成常量,或把 a 的赋值整体搬走。当前 pass 的小规则是让依赖调用的 prepare 位于 a 的原赋值之后;真实 Go resolve 仍在第二条原调用处领取结果。

4. 分支和参数错误:不能跨越 Python 的实际控制流

下面两个用例分别制造控制依赖和参数求值失败。它们关注的是“没有执行的路径”和“尚未到达的错误”是否被提前触发。

guest/test_plm.py · L44–L50 · 打开完整源码

    def test_branch_not_hoisted(self):
        _, events, _, _ = self.execute('if inputs["yes"]:\n a = lookup(key="book")\nelse:\n b = lookup(key="shipping")', {"yes": False})
        self.assertEqual(events, [("prepare", "shipping"), ("resolve", "shipping")])

    def test_missing_argument_not_raised_before_first_call(self):
        _, events, _, _ = self.execute('try:\n a = lookup(key="book")\n b = lookup(key=inputs["missing"])\nexcept KeyError:\n result = a')
        self.assertEqual(events, [("prepare", "book"), ("resolve", "book")])

test_branch_not_hoisted 令 inputs["yes"] 为 false。源码的 true 分支读取 book,else 分支读取 shipping;执行后事件只能是 prepare/resolve shipping。这个结果说明递归 rewrite 保留了 If 节点,未选分支的 prepare 没有被拉到 If 之前。

test_missing_argument_not_raised_before_first_call 先调用 book,再让第二个工具读取不存在的 inputs["missing"]。第一个工具的 prepare 和 resolve 都必须完成;第二个 prepare 的 thunk 捕获 KeyError 并返回 None,因此不会记录第二个 prepare。随后 Python 到达原第二条赋值时,仍会先求值关键字参数,KeyError 在该位置发生并进入 handler。

所以事件只包含 book 的 prepare 和 resolve。该测试证明的是错误没有越过前一个赋值提前抛出;它不证明 prepare 能修复缺失输入,也不证明真实 Go 收到过第二个有效请求。

5. 不支持就保持原树,helper 也不能覆盖用户变量

最后两类用例验证回退和生成名字。回退是全局的:不支持的程序不拿部分节点去试改写。

guest/test_plm.py · L52–L61 · 打开完整源码

    def test_unsupported_is_unchanged(self):
        for source in ['for x in []:\n a=lookup(key=x)', 'f = lambda: 1\nresult=f()', 'inputs["x"]=1', 'lookup = 1', 'import os\nresult=1']:
            tree, helpers = transform(source, [{"name": "lookup", "allow_early_read": True}])
            self.assertIsNone(helpers)
            self.assertEqual(ast.dump(tree), ast.dump(ast.parse(source)))

    def test_no_helper_name_collision(self):
        scope, _, _, helpers = self.execute('_spine_prepare = 123\na = lookup(key="book")\nresult = _spine_prepare')
        self.assertEqual(scope["result"], 123)
        self.assertNotEqual(helpers[0], '_spine_prepare')

test_unsupported_is_unchanged 依次给出循环、lambda 后的调用、对 inputs 的变异、重绑定工具名和 import。每个输入都要求 helpers is None,并用 ast.dump 比较返回树与原始 parse 树的结构。这里检查的是 transform 回退,不是这些 Python 程序本身不能运行。

test_no_helper_name_collision 先由用户把 _spine_prepare 绑定为 123,再放入一个可转换的 lookup 调用,最后读取用户变量。执行结果必须仍为 123,且返回的第一个 helper 名不能是 _spine_prepare。这证明 fresh 会避开已有名字;它是变量冲突保护,不是依靠秘密前缀建立权限或安全边界。

文件末尾的条件入口只负责直接运行 unittest.main();被测试模块导入时不会自动启动测试。它不改变测试夹具或 transform 语义。

guest/test_plm.py · L63–L65 · 打开完整源码


if __name__ == "__main__":
    unittest.main()

6. 这组本地测试能证明什么

六个测试共同覆盖:

  • 相邻独立调用的 prepare/resolve 事件顺序;
  • 工具结果依赖的 prepare 位置和原赋值保留;
  • 未选分支不提前执行;
  • 参数读取错误留在原 Python 调用处;
  • 不支持语法返回原 AST;
  • helper 名称不覆盖用户变量。

其中,事件表证明“替身执行时观察到的顺序”,树结构断言证明“改写节点位于哪里”。两种断言配合,才不会只凭最终的 26 猜测树被正确改写。

证据边界必须保持清楚:本地 prepare 返回的是 (name,args) token,不是 Python 可见的生产整数;本地 resolve 不查 map,也不等待 done;测试没有 Guest Wasm、Host Manifest 再检查、参数 JSON 匹配、worker 取消或 WaitGroup。那些由 plm-tests.md 的真实测试覆盖,futures.md 解释其 Go 状态机。

运行命令是:

PYTHONPATH=guest python3 -m unittest guest/test_plm.py -v

下一步回到 plm-pass.md,把每个断言对应到 data、block_ok、rewrite 和递归控制流;再对照 futures.md,看真实整数 handle 为什么仍要在原位 resolve。