tasklab.run 已有一个可选 workload 回调:它接收任务文本,在词频计算前运行,但它的返回值不会成为任务结果。如果给它包一层事件记录器,静态检查器还能看见原处理器的参数和返回值吗?只标成 Callable[..., object] 虽然方便,却会让 label=42 之类错误在调用处溜过去。这个选修保留应用的词频提交路径,只让处理器扩展边界在代码里变得可检查。

主线14 的协议讲运行时对象能否按所需操作使用,18 的类型提示已经把静态断言和动态输入校验分开。这里再加一个问题:装饰处理器之后如何不丢签名?

只包现有钩子,不另造执行器

在 src/tasklab/optional_plugins.py 中,可调用协议按输入参数和返回值参数化:

1
2
3
4
5
6
Parameters = ParamSpec("Parameters")
ReturnValue = TypeVar("ReturnValue", covariant=True)

class Processor(Protocol[Parameters, ReturnValue]):
def __call__(self, *args: Parameters.args,
**kwargs: Parameters.kwargs) -> ReturnValue: ...

Protocol 使用结构匹配:处理函数不必继承 Processor;只要它的调用签名兼容,静态检查器就接受。Parameters 不是单个参数的类型,它把一个调用的参数列表(含关键字形状)传给包装后的可调用对象。ReturnValue 表达返回值的类型;作为只返回该值的协议参数,它按协变方式声明。普通函数的参数兼容性仍要遵守逆变方向,不能拿接收 int 的函数充当需要 str 的处理器。Typing 规范的泛型与 ParamSpec及协议说明这些静态规则;运行时 Python 不会逐次调用检查器。

observed(processor, events) 返回同一 Processor[Parameters, ReturnValue],先记 entered,在 finally 中记 exited,然后把原调用的所有参数转发给处理器。使用 functools.wraps 是保留可观察的包装元数据,不替代 ParamSpec 的静态签名。例子里的 plugin(text: str) -> int 返回 len(text);将它包起来后,Pyright 接受把结果当成 Processor[[str], int]。这只是类型边界的演示,不能误读成生产任务现在用插件返回值覆盖了词频。

真正接入任务的是 run(store, task_id, workload=observed(...))。事件序列实测为 entered → workload:one two one → exited,任务最终 succeeded,查询结果 {"one":2,"two":1};另一个手工调用的返回值 3 不进入数据库或内存任务结果。没有修改 submit、run、query、cancel 或并发执行策略,也没有开放用户指定任意模块名的加载器。

静态拒绝不等于运行时清洗

负例 experiments/E02_static_negative.py 做两件事:将 def wrong_argument_type(number: int) -> None 放进期望 Processor[[str], None] 的变量,再将整数传给包装后的关键字参数 label: str。冻结的 Pyright 1.1.404 报出两条错误,退出码为 1;正例与模块代码一起检查为零错误、退出码 0。不是把负例脚本执行失败叫作静态检查成功:脚本直接运行时也以 1 退出,但 traceback 在处理器的 len(label),原因是其实现无法对 int 求长度,并非标注在运行时挡住了调用。

运行入口继续单独验证动态数据。submit(store, "word_count", 42) 按现有合同抛 TypeError: text must be a string,提交之前任务集合仍空;类型提示并不会替它完成这一检查。即使插件拥有正确签名,插件内部也可能抛错:测试用可控 ValueError 验证包装器先记录 exited,原 run 将任务标为 failed 并交还 TaskExecutionError。事件日志说明 Python 级 finally 执行过,不证明外部副作用已撤回、事务已提交或任务被及时取消。22 的业务不变量依旧由运行时断言负责。

下面的路径分别关住两种错误:

1
2
3
源码签名 ── Pyright ──→ 不匹配的插件调用在开发时被报出
用户文本 ── submit 校验 ──→ 合法任务 ── run(workload) ──→ 原词频结果
└─ 插件故障 → finally 事件 → failed 状态

类型错误越早暴露越好,却不能因检查器全绿而删除 submit 校验;反过来,运行校验能拒绝 42,也不能证明未经标注的处理器的返回契约。只在已知处理器的调用边界使用该包装器。想让处理器决定任务结果,还需要另行定义允许的结果形状、异常与事务规则,那不是这次的最小增量。

复验与练习

本次 completion 使用 Pyright 1.1.404 和当前构建的 tasklab-0.1.0-py3-none-any.whl;wheel 路径、当前 SHA-256 和成员清单以 examples/python-language/evidence/completion/acceptance/wheel.json 为准。examples/python-language/evidence/completion/additional/static-positive.stdout 记录 3 个文件静态检查、0 个错误;static-E02-negative.stdout 记录 2 个错误,分别是 wrong_argument_type 不能赋给 Processor[(str), None],以及 label=42 不能赋给 str。runtime-E02-negative.stderr 是忽略静态错误后真实执行的 TypeError,acceptance/installed-E02_static_positive.stdout 从已安装环境记录返回值 3 和两轮 entered/exited。累计回归的最终状态以 examples/python-language/evidence/completion/acceptance/ 中的本轮日志为准;PyPy 全套结果见 E04 的实现边界。

预测题。 对 observed(named, events)("one", label=42),分别预测静态检查、直接运行和包装器事件顺序;named 函数会计算 len(text) + len(label)。答案:Pyright 在调用点报 label 应为 str;若忽略静态错误直接运行,进入函数后 len(42) 抛 TypeError,finally 仍写入 exited,事件是 entered, exited。静态拒绝不会使这段代码自动无法执行,可核对 static-negative.stdout 与 runtime-negative.stderr。

变更题。 把 run(..., workload=...) 的处理器改为抛 ValueError,是否仍能得到 succeeded 的词频结果?答案:不能;包装器保留异常传播并记录退出,run 的现有错误路径将状态写成 failed,调用方收到 TaskExecutionError。在对应单元测试中要同时检查 events == ["entered", "exited"] 和 query(...)["state"] == "failed";光看到异常并不足以确认业务终态。

参考资料

  • Python Typing 规范:Generics、Protocols:参数规格与结构类型的规则。所访问的文档是规范页面,不等于本机 Python 补丁版本。
  • examples/python-language/src/tasklab/optional_plugins.py、examples/python-language/tests/test_optional_plugins.py:可移除包装层与正常/故障/输入拒绝断言。
  • examples/python-language/evidence/completion/additional/static-positive.stdout、static-E02-negative.stdout、runtime-E02-negative.stderr 与 examples/python-language/evidence/completion/acceptance/installed-E02_static_positive.stdout:工具版本、当前 wheel、静态正反例、安装和原始日志。