docs: 为全部代码补充中文注释并加入 AGENTS 注释规范

This commit is contained in:
cat-shark
2026-08-13 22:09:55 +08:00
parent 906553d2d0
commit 439b63c81b
7 changed files with 150 additions and 0 deletions
+7
View File
@@ -19,3 +19,10 @@
- Python 环境统一使用 uv 管理,不直接使用 pip 修改依赖。
- 测试必须达到 100% 行覆盖率,且只能通过调用真实代码路径覆盖。
## 代码注释规范
- 本仓库所有源码(Python、TOML 等支持注释的文件)必须配有详细中文注释,说明模块职责、协议模型字段语义、类与函数的作用以及关键逻辑,确保后续维护人员可以快速理解代码工作原理。
- 新增或修改代码时,必须同步补充或更新对应注释;不得删除已有注释。
- 测试代码同样必须配有中文注释,说明每条测试验证的行为。
- JSON 数据文件不支持注释,字段语义以本仓库 `models.py` 中的模型注释为准;修改 JSON 字段时须同步更新文档。
+5
View File
@@ -1,19 +1,24 @@
# WOV SDK 项目配置:使用 uv 管理环境与依赖。
[project]
name = "wov-sdk"
version = "0.1.0"
description = "WOV node protocol and SDK"
requires-python = ">=3.11"
# 开发依赖放在 dev 组,由 uv sync 默认安装。
[dependency-groups]
dev = ["pytest", "pytest-cov"]
# pytest 配置:只扫描 tests 目录,并强制 100% 行覆盖率。
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--cov=wov_sdk --cov-report=term-missing --cov-fail-under=100"
# 作为可发布包处理,便于节点仓库通过路径依赖引用。
[tool.uv]
package = true
# 使用 src 布局,源码位于 src/wov_sdk。
[tool.setuptools]
package-dir = {"" = "src"}
+6
View File
@@ -1,3 +1,8 @@
"""WOV SDK 公共导出入口。
节点仓库和 wov-api 统一从 wov_sdk 导入协议模型,而无需关心具体模块路径。
"""
from wov_sdk.models import (
HealthResponse,
InvokeRequest,
@@ -9,6 +14,7 @@ from wov_sdk.models import (
WorkflowNode,
)
# 对外稳定的公共 API 清单;新增模型时必须同步追加到这里。
__all__ = [
"HealthResponse",
"InvokeRequest",
+77
View File
@@ -1,3 +1,10 @@
"""WOV 节点协议核心数据模型。
本模块定义节点 Manifest、调用请求/响应、健康检查、进度事件以及工作流 DAG 的
通用数据结构。所有节点仓库和 wov-api 都依赖这些类,字段语义必须长期保持稳定,
新增能力时只能向后兼容地扩展字段。
"""
from __future__ import annotations
import json
@@ -6,32 +13,54 @@ from typing import Any
def _require_non_empty(value: str, name: str) -> None:
"""校验必填字符串字段,空字符串或纯空白字符串都会被拒绝。"""
if not value or not value.strip():
raise ValueError(f"{name} must not be empty")
@dataclass
class NodeManifest:
"""节点注册清单:描述节点能力、启动命令、生命周期与资源参数。
该清单由节点仓库根目录的 node.manifest.json 提供,也通过管理 API 注册到
wov-api 的 SQLite 节点注册表中。NodeManager 依据它启动和回收节点进程。
"""
# 节点稳定唯一 ID,例如 faster-whisper;注册后不可随意更改。
id: str
# 展示名称,仅用于管理后台等界面。
name: str
# 节点版本号,与节点仓库 Git tag 保持一致。
version: str
# 能力标识,工作流通过 node_type 引用能力,而不是直接绑定具体仓库。
capability: str
# 启动命令;首项为 python 时 NodeManager 会替换为节点仓库 .venv 解释器。
command: list[str]
# 节点代码所在子仓库目录,相对 WOV 工作区根目录。
repo_dir: str = "."
# 启动节点进程时额外注入的环境变量。
env: dict[str, str] = field(default_factory=dict)
# 输入字段 schema,当前主要用于文档展示,后续可用于运行时校验。
input_schema: dict[str, Any] = field(default_factory=dict)
# 输出字段 schema,用于描述节点产物的名称与类型。
output_schema: dict[str, Any] = field(default_factory=dict)
# 单个实例允许并发承载的调用数;1 表示严格串行复用。
max_concurrency: int = 1
# 空闲回收 TTL(秒);0 表示调用结束即可被回收。
idle_ttl_seconds: int = 300
# 节点进程启动后等待就绪输出的超时(秒)。
health_timeout_seconds: int = 10
# 常驻标记;开启后即使空闲超过 TTL 也不会被回收。
keep_warm: bool = False
def validate(self) -> None:
"""校验 manifest 必填字段与数值边界,非法配置抛出 ValueError。"""
_require_non_empty(self.id, "id")
_require_non_empty(self.name, "name")
_require_non_empty(self.version, "version")
_require_non_empty(self.capability, "capability")
_require_non_empty(self.repo_dir, "repo_dir")
# 命令不能为空,否则节点进程无法启动。
if not self.command:
raise ValueError("command must not be empty")
if self.max_concurrency < 1:
@@ -42,6 +71,7 @@ class NodeManifest:
raise ValueError("health_timeout_seconds must be >= 1")
def to_dict(self) -> dict[str, Any]:
"""转换为可 JSON 序列化的普通字典。"""
return {
"id": self.id,
"name": self.name,
@@ -60,16 +90,19 @@ class NodeManifest:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "NodeManifest":
"""从注册 API 或 JSON 文件解析出的字典恢复 manifest。"""
return cls(
id=str(data["id"]),
name=str(data["name"]),
version=str(data["version"]),
capability=str(data["capability"]),
# command 可能缺省,解析时提供空列表兜底。
command=[str(item) for item in data.get("command", [])],
repo_dir=str(data.get("repo_dir", ".")),
env={str(k): str(v) for k, v in data.get("env", {}).items()},
input_schema=dict(data.get("input_schema", {})),
output_schema=dict(data.get("output_schema", {})),
# 数值字段缺省时使用与 dataclass 一致的默认值。
max_concurrency=int(data.get("max_concurrency", 1)),
idle_ttl_seconds=int(data.get("idle_ttl_seconds", 300)),
health_timeout_seconds=int(data.get("health_timeout_seconds", 10)),
@@ -78,6 +111,7 @@ class NodeManifest:
@classmethod
def load(cls, path: str) -> "NodeManifest":
"""从磁盘上的 node.manifest.json 加载并校验 manifest。"""
with open(path, "r", encoding="utf-8") as f:
manifest = cls.from_dict(json.load(f))
manifest.validate()
@@ -86,13 +120,21 @@ class NodeManifest:
@dataclass
class InvokeRequest:
"""节点调用请求:由调度器或管理后台发送给节点 HTTP 服务。"""
# 工作流运行 ID,用于追踪一次完整执行。
run_id: str
# 实际承载本次调用的节点实例 ID,由 NodeManager 回填。
node_instance_id: str
# 输入产物映射,key 为输入名,value 为产物 URI 或直接文本。
inputs: dict[str, Any] = field(default_factory=dict)
# 节点运行参数,例如采样率、语言、模型路径等。
params: dict[str, Any] = field(default_factory=dict)
# 节点产物输出目录。
output_dir: str = "."
def to_dict(self) -> dict[str, Any]:
"""转换为可 JSON 序列化的普通字典。"""
return {
"run_id": self.run_id,
"node_instance_id": self.node_instance_id,
@@ -103,6 +145,7 @@ class InvokeRequest:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "InvokeRequest":
"""从 HTTP 请求 JSON 解析调用请求。"""
return cls(
run_id=str(data["run_id"]),
node_instance_id=str(data["node_instance_id"]),
@@ -114,11 +157,17 @@ class InvokeRequest:
@dataclass
class InvokeResponse:
"""节点调用响应:completed 表示成功,failed 表示执行失败。"""
# 执行状态,固定为 completed / failed。
status: str
# 输出产物映射,key 为输出名,value 为产物 URI。
outputs: dict[str, Any] = field(default_factory=dict)
# 失败原因,仅在 failed 时有意义。
error: str | None = None
def to_dict(self) -> dict[str, Any]:
"""转换为可 JSON 序列化的普通字典。"""
return {
"status": self.status,
"outputs": self.outputs,
@@ -127,7 +176,9 @@ class InvokeResponse:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "InvokeResponse":
"""从 HTTP 响应 JSON 解析调用结果。"""
return cls(
# 缺省按失败处理,避免未知状态被误判为成功。
status=str(data.get("status", "failed")),
outputs=dict(data.get("outputs", {})),
error=data.get("error"),
@@ -136,11 +187,14 @@ class InvokeResponse:
@dataclass
class HealthResponse:
"""节点健康检查响应:节点进程就绪后返回 ok 与自身标识。"""
status: str
node_id: str
version: str
def to_dict(self) -> dict[str, Any]:
"""转换为可 JSON 序列化的普通字典。"""
return {
"status": self.status,
"node_id": self.node_id,
@@ -150,12 +204,15 @@ class HealthResponse:
@dataclass
class ProgressEvent:
"""进度事件:预留用于节点向调度器上报执行进度。"""
run_id: str
node_id: str
progress: float
message: str | None = None
def to_dict(self) -> dict[str, Any]:
"""转换为可 JSON 序列化的普通字典。"""
return {
"run_id": self.run_id,
"node_id": self.node_id,
@@ -166,12 +223,19 @@ class ProgressEvent:
@dataclass
class WorkflowNode:
"""工作流中的一个节点:声明节点类型、参数和输入引用。"""
# 节点在 DAG 内的唯一 ID,例如 extract、asr。
id: str
# 引用的节点能力,例如 ffmpeg-extract、faster-whisper。
node_type: str
# 传递给节点 invoke 的 params。
params: dict[str, Any] = field(default_factory=dict)
# 输入引用,value 形如 "前序节点ID.输出名" 或 "input.入口字段"。
inputs: dict[str, str] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
"""转换为可 JSON 序列化的普通字典。"""
return {
"id": self.id,
"node_type": self.node_type,
@@ -181,6 +245,7 @@ class WorkflowNode:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "WorkflowNode":
"""从工作流定义 JSON 解析节点。"""
return cls(
id=str(data["id"]),
node_type=str(data["node_type"]),
@@ -191,30 +256,40 @@ class WorkflowNode:
@dataclass
class WorkflowEdge:
"""工作流有向边:from_node 的输出流向 to_node 的输入。"""
from_node: str
to_node: str
def to_dict(self) -> dict[str, Any]:
"""转换为 JSON 时使用 from/to 短字段名。"""
return {"from": self.from_node, "to": self.to_node}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "WorkflowEdge":
"""从工作流定义 JSON 解析边。"""
return cls(from_node=str(data["from"]), to_node=str(data["to"]))
@dataclass
class WorkflowDefinition:
"""工作流 DAG 定义:包含节点列表、依赖边和输入输出映射。"""
name: str
version: int
nodes: list[WorkflowNode] = field(default_factory=list)
edges: list[WorkflowEdge] = field(default_factory=list)
# 用户上传入口与入口字段名的映射,例如 {"video_uri": "file"}。
entry_inputs: dict[str, Any] = field(default_factory=dict)
# 最终对外暴露的产物别名映射,例如 {"ass": "ass.ass_uri"}。
final_outputs: dict[str, Any] = field(default_factory=dict)
def validate(self) -> None:
"""校验 DAG 基本约束:名称、版本、节点 ID 唯一、边引用有效。"""
_require_non_empty(self.name, "name")
if self.version < 1:
raise ValueError("version must be >= 1")
# 节点 ID 集合用于检查重复和边引用。
node_ids = {node.id for node in self.nodes}
if len(node_ids) != len(self.nodes):
raise ValueError("workflow node ids must be unique")
@@ -223,6 +298,7 @@ class WorkflowDefinition:
raise ValueError(f"edge references unknown node: {edge}")
def to_dict(self) -> dict[str, Any]:
"""转换为可 JSON 序列化的普通字典。"""
return {
"name": self.name,
"version": self.version,
@@ -234,6 +310,7 @@ class WorkflowDefinition:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "WorkflowDefinition":
"""从工作流定义 JSON 解析 DAG。"""
return cls(
name=str(data["name"]),
version=int(data.get("version", 1)),
+25
View File
@@ -1,3 +1,10 @@
"""标准节点 HTTP 服务端。
每个节点仓库通过 run_node 启动自己的 HTTP 服务,对外提供 GET /health 与
POST /invoke 两个协议端点。wov-api 的 NodeManager 通过读取启动时打印的
WOV_NODE_READY 行获得端口,再据此进行健康检查和调用。
"""
from __future__ import annotations
import json
@@ -6,12 +13,15 @@ from typing import Callable
from wov_sdk.models import HealthResponse, InvokeRequest, InvokeResponse, NodeManifest
# 节点业务处理函数签名:接收 InvokeRequest,返回 InvokeResponse。
InvokeHandler = Callable[[InvokeRequest], InvokeResponse]
def _send_json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
"""以 UTF-8 JSON 形式向客户端写出 HTTP 响应。"""
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
handler.send_response(status)
# 显式声明 JSON 字符集,保证中文内容在旧客户端下也能正确解析。
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Content-Length", str(len(body)))
handler.end_headers()
@@ -19,7 +29,11 @@ def _send_json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> N
class NodeHTTPServer(ThreadingHTTPServer):
"""线程化节点 HTTP 服务器,保存 manifest 和业务处理函数。"""
# 每个请求使用独立线程,避免单个慢请求阻塞其他调用。
daemon_threads = True
# 允许地址复用,方便节点快速重启。
allow_reuse_address = True
def __init__(
@@ -28,18 +42,23 @@ class NodeHTTPServer(ThreadingHTTPServer):
manifest: NodeManifest,
handler: InvokeHandler,
) -> None:
"""绑定节点清单与调用处理器,并使用内部请求处理器提供服务。"""
self.manifest = manifest
self.invoke_handler = handler
super().__init__(server_address, _NodeRequestHandler)
class _NodeRequestHandler(BaseHTTPRequestHandler):
"""节点协议请求处理器:只实现 /health 与 /invoke 两个端点。"""
server: NodeHTTPServer
def log_message(self, format: str, *args) -> None: # noqa: A002
"""静默访问日志,避免测试和日志系统产生噪声。"""
return
def do_GET(self) -> None:
"""处理健康检查请求,返回节点 ID 与版本。"""
if self.path != "/health":
_send_json(self, 404, {"status": "error", "message": "not found"})
return
@@ -51,16 +70,19 @@ class _NodeRequestHandler(BaseHTTPRequestHandler):
_send_json(self, 200, response.to_dict())
def do_POST(self) -> None:
"""处理调用请求,业务异常统一转换为 failed 响应。"""
if self.path != "/invoke":
_send_json(self, 404, {"status": "error", "message": "not found"})
return
try:
# 按 Content-Length 精确读取请求体,避免长连接下的读悬挂。
length = int(self.headers.get("Content-Length", "0"))
body = json.loads(self.rfile.read(length).decode("utf-8"))
request = InvokeRequest.from_dict(body)
response = self.server.invoke_handler(request)
_send_json(self, 200, response.to_dict())
except Exception as exc: # noqa: BLE001
# 任何处理异常都不能让节点进程崩溃,统一返回失败结果。
_send_json(
self,
500,
@@ -74,6 +96,7 @@ def create_node_server(
host: str = "127.0.0.1",
port: int = 0,
) -> NodeHTTPServer:
"""创建节点服务器;manifest 校验通过后才允许启动。"""
manifest.validate()
return NodeHTTPServer((host, port), manifest, invoke_handler)
@@ -84,7 +107,9 @@ def run_node(
host: str = "127.0.0.1",
port: int = 0,
) -> None:
"""启动节点服务并阻塞运行,直到进程被外部终止。"""
server = create_node_server(manifest, invoke_handler, host=host, port=port)
# 节点管理器从标准输出解析该行,因此必须 flush。
print(f"WOV_NODE_READY port={server.server_address[1]}", flush=True)
try:
server.serve_forever()
+16
View File
@@ -1,3 +1,9 @@
"""wov_sdk.models 的单元测试。
测试覆盖所有数据模型的 JSON 往返序列化、字段校验和 manifest 文件加载,
确保协议模型的稳定性。
"""
import json
import pytest
@@ -15,6 +21,7 @@ from wov_sdk.models import (
def valid_manifest() -> NodeManifest:
"""构造一个覆盖全部字段的合法 NodeManifest,供测试复用。"""
return NodeManifest(
id="echo",
name="Echo",
@@ -33,6 +40,7 @@ def valid_manifest() -> NodeManifest:
def test_manifest_round_trip() -> None:
"""验证 manifest 经过 to_dict/from_dict 后保持原值。"""
manifest = valid_manifest()
restored = NodeManifest.from_dict(manifest.to_dict())
assert restored == manifest
@@ -53,6 +61,7 @@ def test_manifest_round_trip() -> None:
],
)
def test_manifest_validation(field: str, value: object) -> None:
"""验证必填字段为空或数值越界时抛出 ValueError。"""
manifest = valid_manifest()
setattr(manifest, field, value)
with pytest.raises(ValueError):
@@ -60,6 +69,7 @@ def test_manifest_validation(field: str, value: object) -> None:
def test_manifest_load(tmp_path) -> None:
"""验证 NodeManifest.load 能从 JSON 文件读取并校验。"""
path = tmp_path / "node.manifest.json"
path.write_text(json.dumps(valid_manifest().to_dict()), encoding="utf-8")
loaded = NodeManifest.load(str(path))
@@ -67,6 +77,7 @@ def test_manifest_load(tmp_path) -> None:
def test_invoke_request_round_trip() -> None:
"""验证 InvokeRequest 的 JSON 往返序列化。"""
request = InvokeRequest(
run_id="run_1",
node_instance_id="ni_1",
@@ -79,12 +90,14 @@ def test_invoke_request_round_trip() -> None:
def test_invoke_response_round_trip() -> None:
"""验证 InvokeResponse 的 JSON 往返序列化。"""
response = InvokeResponse(status="completed", outputs={"text": "hello"})
restored = InvokeResponse.from_dict(response.to_dict())
assert restored == response
def test_health_and_progress_serialization() -> None:
"""验证健康检查和进度事件模型的字典输出。"""
health = HealthResponse(status="ok", node_id="echo", version="1.0.0")
assert health.to_dict() == {
"status": "ok",
@@ -102,6 +115,7 @@ def test_health_and_progress_serialization() -> None:
def test_workflow_node_and_edge_round_trip() -> None:
"""验证工作流节点与边的 JSON 往返序列化。"""
node = WorkflowNode(
id="asr",
node_type="faster-whisper",
@@ -117,6 +131,7 @@ def test_workflow_node_and_edge_round_trip() -> None:
def test_workflow_definition_round_trip_and_validation() -> None:
"""验证完整 DAG 定义可往返序列化并通过校验。"""
definition = WorkflowDefinition(
name="demo",
version=1,
@@ -134,6 +149,7 @@ def test_workflow_definition_round_trip_and_validation() -> None:
def test_workflow_definition_invalid() -> None:
"""验证非法 DAG(空名、版本为 0、重复节点、未知边)被拒绝。"""
with pytest.raises(ValueError):
WorkflowDefinition(name="", version=1).validate()
+14
View File
@@ -1,3 +1,9 @@
"""wov_sdk.server 的协议测试。
通过真实 HTTP 请求验证 /health 与 /invoke 端点的正常、404 和 500 分支,
并验证 run_node 的启动输出格式。
"""
import json
import threading
import urllib.error
@@ -10,6 +16,7 @@ from wov_sdk.server import NodeHTTPServer, create_node_server, run_node
def valid_manifest() -> NodeManifest:
"""构造一个合法的最小 Echo 节点 manifest。"""
return NodeManifest(
id="echo",
name="Echo",
@@ -20,6 +27,7 @@ def valid_manifest() -> NodeManifest:
def test_create_node_server_validates_manifest() -> None:
"""验证空 manifest 在创建服务器前就会被校验拒绝。"""
with pytest.raises(ValueError):
create_node_server(
NodeManifest(
@@ -34,6 +42,7 @@ def test_create_node_server_validates_manifest() -> None:
def _post(server: NodeHTTPServer, path: str, body: dict | None = None) -> tuple[int, dict]:
"""向节点服务器发送 POST 请求并返回状态码与 JSON 响应。"""
data = json.dumps(body or {}).encode("utf-8")
request = urllib.request.Request(
f"http://127.0.0.1:{server.server_address[1]}{path}",
@@ -49,6 +58,7 @@ def _post(server: NodeHTTPServer, path: str, body: dict | None = None) -> tuple[
def _get(server: NodeHTTPServer, path: str) -> tuple[int, dict]:
"""向节点服务器发送 GET 请求并返回状态码与 JSON 响应。"""
try:
with urllib.request.urlopen(
f"http://127.0.0.1:{server.server_address[1]}{path}", timeout=5
@@ -59,6 +69,7 @@ def _get(server: NodeHTTPServer, path: str) -> tuple[int, dict]:
def test_http_protocol() -> None:
"""验证健康检查、正常调用、404 与非法请求体等协议分支。"""
def handler(request: InvokeRequest) -> InvokeResponse:
return InvokeResponse(
status="completed",
@@ -66,6 +77,7 @@ def test_http_protocol() -> None:
)
server = create_node_server(valid_manifest(), handler)
# 在独立线程中运行服务器,主线程执行 HTTP 客户端断言。
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
@@ -103,6 +115,7 @@ def test_http_protocol() -> None:
def test_http_protocol_handler_error() -> None:
"""验证业务处理函数抛异常时返回 500 与 failed 状态。"""
def failing_handler(request: InvokeRequest) -> InvokeResponse:
raise RuntimeError("boom")
@@ -130,6 +143,7 @@ def test_http_protocol_handler_error() -> None:
def test_run_node(monkeypatch, capsys) -> None:
"""验证 run_node 打印 WOV_NODE_READY 端口行并进入服务循环。"""
called = []
def fake_serve_forever(server: NodeHTTPServer) -> None: