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

Python 执行约定

guest/bootstrap.py

本章目录
配套饭点图解 · 3 节(按需展开)

这一章回答:C 已经把请求交到 CPython 后,Python 层怎样决定用户能看到什么、怎样调用 Host 工具,以及怎样把成功/失败重新编码给 Go?调用者是 runtime.c 缓存的 execute、prefix_begin、prefix_feed;输入是 JSON 字符串和源码片段;最终交付是 UTF-8 JSON bytes。当前协议的关键点是:请求 manifest 描述工具,Python 为每个名称建立 proxy,所以用户写 lookup(key=...),函数名称和 Host 实现由同一份 manifest 对齐。

普通执行和工具回调有两条相反方向的路径:

Go execute JSON → execute → exec 用户源码
用户 lookup(...) → make_tool proxy → invoke_tool
  → _spine.call/resolve → Go bridge → JSON response
  → decode → Python 值或 RuntimeError

Python 负责 JSON、作用域、AST 变换和异常 envelope;C 负责 bytes/长度,Go 负责工具实现、handle 和 Guest 内存边界。

1. 模块加载时只建立桥和 prefix 哨兵

CPython 导入本模块时还没有用户源码,也没有本轮输入。它导入 C 注册的 _spine 三个函数,把 _prefix 设为 None,随后等待 execute 或 prefix 入口。

guest/bootstrap.py · L1–L8 · 打开完整源码

"""Execution convention: inputs in, result out; optional whole-program PLM."""
import json
import sys
from _spine import call, prepare, resolve

_prefix = None

call、prepare、resolve 是当前模块的全局名字,来自 C 模块,不是用户 scope 中自动注入的变量。_prefix=None 把“普通执行还没有源码接收状态”表达成明确哨兵。用户程序稍后运行在另一个 scope 字典里;工具 proxy 的函数体仍按定义位置从 bootstrap 全局查找这些桥接名字。

2. 普通工具调用:名称在 Python,执行权在 Host

decode 接收 C 返回的 response bytes,解析为 dict;有 error 就在当前 Python 调用位置抛 RuntimeError,否则取 value。这样 Host 工具错误先作为协议数据跨边界,再转换成用户可捕获的 Python 异常。

guest/bootstrap.py · L9–L21 · 打开完整源码

def decode(response):
    response = json.loads(response)
    if "error" in response:
        raise RuntimeError(response["error"])
    return response["value"]


def invoke_tool(name, args):
    request = json.dumps({"tool": name, "args": args})
    handle = _prefix.claim(request) if _prefix is not None else None
    return decode(call(request) if handle is None else resolve(handle, request))

invoke_tool(name, args) 把函数名和 kwargs 组成 {"tool": name, "args": args},用 json.dumps 生成请求。若 prefix 已有队首匹配 handle,就走 resolve(handle, request);否则走普通 call(request)。两条路径都交给 decode,所以调用者只看到 Python 值或 RuntimeError。

这里没有一个通用的 tool 函数。invoke_tool 是内部协议函数,用户看到的名称由下面的 make_tool 逐个生成。Python dict 和 Go 的 json.RawMessage 之间不共享对象,必须通过 JSON 文本经过 C/Host。

guest/bootstrap.py · L22–L28 · 打开完整源码

def make_tool(name):
    def proxy(**args):
        return invoke_tool(name, args)
    proxy.__name__ = name
    return proxy

make_tool(name) 返回一个只接收关键字参数的闭包 proxy。闭包调用 invoke_tool(name, args),并把 proxy.__name__ 设置为 manifest 中的名称。execute 会为每个 manifest spec 调一次 make_tool,因此用户 scope 中会出现 lookup 这样的具名函数。闭包捕获的是名称,不是某个预先执行的工具结果。

3. PLM 提前调用如何保留原位语义

PLM 需要一个 thunk,让它能尝试读取工具参数,但又不能把参数错误提前变成用户程序更早看到的异常。prepare_call 接收 thunk,成功求值后组装和普通调用相同的请求;prefix 队首已有 handle 时直接复用,否则调用 C 的 prepare。

guest/bootstrap.py · L29–L41 · 打开完整源码

def prepare_call(thunk):
    try:
        name, args = thunk()
        request = json.dumps({"tool": name, "args": args})
        handle = _prefix.claim(request) if _prefix is not None else None
        return prepare(request) if handle is None else handle
    except Exception:
        # Argument lookup/encoding errors belong at the original Python call, not here.
        return 0


def resolve_call(handle, name, **args):
    return decode(resolve(handle, json.dumps({"tool": name, "args": args})))

prepare_call 捕获 Exception,参数查找、JSON 编码或读取失败就返回 0。0 只代表没有可提前领取的结果,不能当作业务值。它没有捕获 BaseException,因此不吞掉更宽的控制信号。resolve_call 不包这一层 try:它正位于原始工具调用的交付点,会重新用当前的 name/args 组装请求并调用 resolve。

这两个函数共同维持一个重要顺序:提前阶段可以启动本轮 worker,但真正的值或工具错误仍在用户源码原来的调用位置交付。Go 会在 resolve 时校验 handle、工具名和参数是否匹配;Python 层不凭一个整数猜结果。

4. prefix 只接收源码,不执行源码

prefix 开始时,C 传入包含 inputs 和 manifest 的初始请求。prefix_begin 解析 JSON,导入 Prefix,创建 _prefix 对象,并把 prepare 函数交给它。后续每个源码片段进入 prefix_feed,由 _prefix.feed 追加和分析。

guest/bootstrap.py · L44–L53 · 打开完整源码

def prefix_begin(request):
    global _prefix
    from prefix import Prefix
    request = json.loads(request)
    _prefix = Prefix(request["inputs"], request["manifest"], prepare)


def prefix_feed(chunk):
    _prefix.feed(chunk)

global _prefix 让初始化写回模块级状态;没有它,赋值会变成局部变量。Prefix 可以在完整、允许的前缀上发起 prepare,但这里没有 compile/exec 用户程序。prefix_feed 正常返回 None;如果状态还没建立,访问 _prefix.feed 会产生异常,由 C wrapper 报告失败。

prefix 的状态属于当前 CPython Guest,不是 Go 全局变量。最终执行时,Go 发送一个小的 {"prefix":true} 请求,execute 再从 _prefix 取回 source、inputs、manifest,并强制启用 PLM 入口。

5. execute 统一普通、PLM 和 prefix 的最终交付

execute 是 Python 层总入口。它先保留一个空的 transformed,再在一个覆盖范围很大的 try 中解析请求、选择 prefix、构造用户 scope、可选地做 AST transform、执行代码并编码响应。

guest/bootstrap.py · L55–L65 · 打开完整源码

def execute(request):
    transformed = ""
    try:
        request = json.loads(request)
        if request.get("prefix"):
            request = {
                "source": _prefix.source,
                "inputs": _prefix.inputs,
                "manifest": _prefix.manifest,
                "plm": True,
            }

普通请求直接取自己的 source、inputs 和 manifest。prefix 请求则从 _prefix 重新组装同样的信息,并把 plm 设为 true;这说明 prefix 的源码接收和最终执行是两个阶段。无论哪条路径,scope 至少包含 __name__ 和 inputs,随后按 manifest 为每个工具名放入 proxy。用户代码拿到的是 lookup 这样的函数,不会自动看到 _prefix、call 或 prepare。

PLM 开启时才导入 ast 和 plm.transform,传入完整 source 与 manifest。transform 返回 AST 和 helper 名称;只有 helper 非空才把 prepare_call/resolve_call 放进 scope,并用 ast.unparse 保存实际变换文本。普通路径不会导入这套优化器。

guest/bootstrap.py · L66–L83 · 打开完整源码

        scope = {"__name__": "__main__", "inputs": request["inputs"]}
        for spec in request["manifest"]:
            scope[spec["name"]] = make_tool(spec["name"])
        code = request["source"]
        if request.get("plm"):
            import ast
            from plm import transform
            code, helpers = transform(code, request["manifest"])
            if helpers:
                scope.update(zip(helpers, (prepare_call, resolve_call)))
                transformed = ast.unparse(code)
        exec(compile(code, "<spine>", "exec"), scope)
        response = {"value": scope.get("result"), "transformed": transformed}
        return json.dumps(response).encode("utf-8")
    except BaseException as error:
        return json.dumps({"error": f"{type(error).__name__}: {error}", "transformed": transformed}).encode("utf-8")
    finally:
        sys.stdout.flush()

exec(compile(code, "<spine>", "exec"), scope) 在同一个字典中创建用户变量和 result。成功后响应包含 scope.get("result") 和变换文本,再 JSON 编码成 UTF-8 bytes。没有给 result 赋值时,交付的 value 是 None;这不是 Python 层自动报错。

except BaseException 把逃出用户执行、请求解析、PLM、编译或结果编码的异常写入 error 字段,并保留已经产生的 transformed 文本。finally 总是 flush stdout;flush 失败也可能成为 C 看到的调用失败。成功/失败 JSON 和 stdout 是两条不同的输出。

一次 lookup 的状态序列

以默认源码为例,scope 建立后有 inputs 和 lookup proxy。第一行调用 lookup(key=inputs["item"]):

  1. proxy 把名称 lookup 和 kwargs 交给 invoke_tool。
  2. invoke_tool 编码请求;普通路径没有 _prefix handle,于是调用 _spine.call。
  3. C/Go 返回 {"value":21} 一类响应,decode 取出 Python 整数。
  4. 用户第二行使用整数和 inputs 组成 result dict。
  5. execute 从 scope 取 result,返回外层 JSON bytes。

如果 Host 返回 error,decode 在第一行抛 RuntimeError;用户若未捕获,execute 将它序列化为 error 字段。若是 PLM,prepare 可能先启动工作,但 resolve_call 仍在原调用位置确认交付。若是 prefix,feed 阶段成功也只表示已更新解析/准备状态,不表示用户赋值执行完毕。

读完本章应能回答:为什么 manifest 要在 Guest 中变成多个具名 proxy、为什么 prepare_call 可以返回 0 而不破坏原调用语义,以及为什么最终响应中的 transformed 只是诊断文本。下一步进入 guest-c.md 对照 bytes 和 packed ABI,或进入 bridge.md 跟 invoke_tool 发出的请求如何匹配 ToolSpec。