specmodule 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,404 @@
1
+ # module_harness/scaffold.py
2
+ """specmodule init 脚手架生成逻辑(纯函数,与 CLI 解析分离)。
3
+
4
+ 生成单文件 python 原生模块骨架(modules/<name>.py)+ 项目级文件缺啥补啥
5
+ (幂等)。模板文本以模块级常量存放,可直接单测。CLI 只 import 不重实现。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ _NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
17
+
18
+
19
+ def validate_module_name(name: str) -> bool:
20
+ """是否合法模块名:合法 Python 标识符。
21
+
22
+ 模块名同时是文件 stem、discover 导入名、--module 选择器、entry.name
23
+ 与默认 run_id——四处必须一致,故限制为标识符。
24
+ """
25
+ return bool(_NAME_RE.match(name or ""))
26
+
27
+
28
+ # ── 项目文件模板(常量)──────────────────────────────────────────────
29
+
30
+ CONFIG_JSON: dict[str, Any] = {
31
+ "providers": [
32
+ {
33
+ "name": "openai",
34
+ "sdktype": "openai",
35
+ "base_url": None,
36
+ "api_key_env": "OPENAI_API_KEY",
37
+ "timeout": 120,
38
+ "max_retries": 3,
39
+ },
40
+ ],
41
+ "models": [],
42
+ }
43
+
44
+ ENV_EXAMPLE = """# 复制为 .env 并填入真实密钥(.env 已被 .gitignore 排除,不进版本库)
45
+ # config.json 的 provider.api_key_env 指定了变量名——两者必须对齐。
46
+ OPENAI_API_KEY=
47
+ """
48
+
49
+ GITIGNORE = """__pycache__/
50
+ *.pyc
51
+ .env
52
+ .specmodule/
53
+ """
54
+
55
+ SPEC_EXAMPLE_JSON: dict[str, Any] = {"message": "你好,世界"}
56
+
57
+ README_MD = """# {name}
58
+
59
+ 由 `specmodule init {name}` 生成的模块骨架。默认模板回显 spec 的 `message`
60
+ 字段(单 script 节点,零 LLM 依赖),`--mock` 即可冒烟。
61
+
62
+ ## 立即冒烟(无需 API key)
63
+
64
+ ```bash
65
+ python -m module_harness.cli run --module {name} --mock
66
+ ```
67
+
68
+ 输出结尾应含 `回显: {{"message": "你好,世界"}}` 一类的节点摘要。
69
+
70
+ ## 真实 LLM 运行
71
+
72
+ 1. 复制 `.env.example` 为 `.env`,填入真实密钥
73
+ (`config.json` 的 `provider.api_key_env` 指定了密钥的环境变量名)。
74
+ 2. 在 `config.json` 的 `models` 里填入你用的模型名。
75
+ 3. 运行:
76
+
77
+ ```bash
78
+ python -m module_harness.cli run --module {name} --spec '{{"message": "..."}}'
79
+ ```
80
+
81
+ ## 配置分工
82
+
83
+ - `config.json`:非敏感 provider/model 注册表(连接信息 + `api_key_env`
84
+ 指向的**变量名**)。
85
+ - `.env`:密钥实际值(gitignored,不进版本库)。
86
+ - `rules.txt`:框架级输出格式约束(可选)。
87
+
88
+ ## 命令
89
+
90
+ | 命令 | 作用 |
91
+ |------|------|
92
+ | `run --module {name} [--spec ...] [--mock]` | 运行模块(三级实时显示 `--verbose 1..3`) |
93
+ | `status [--run-id ...]` | 查询运行状态 |
94
+ | `review [--tick N] [--node ...] [--failed]` | 审阅历史时间线 |
95
+
96
+ `init` 只在文件缺失时补齐项目文件(幂等);已存在的 `config.json`、
97
+ `.gitignore` 等保持原样不被覆盖。`--force` 仅覆盖模块文件
98
+ `modules/{name}.py`。
99
+ """
100
+
101
+ # ── 模块骨架模板(__NAME__ / __DESCRIPTION__ 占位符)────────────────
102
+
103
+ MODULE_TEMPLATE = '''"""{description} — 由 specmodule init 生成的模块骨架。
104
+
105
+ 一个 module 一个 py 文件(modules/{name}.py):本文件内声明模块级 ``entry``
106
+ 变量,CLI ``specmodule run --module {name}`` 经 discover_modules() 导入。
107
+
108
+ 增长路径:模块变大(多模板 / submodule / guard)时,把下方实现拆到独立包
109
+ ``{name}/``,本文件留薄入口——参照 example/modules/academic_writer.py 的形态。
110
+ """
111
+
112
+ from __future__ import annotations
113
+
114
+ from typing import Any
115
+
116
+ from module_harness.config import HarnessConfig
117
+ from module_harness.entry import ModuleEntry
118
+ from module_harness.events import EventBus
119
+ from module_harness.outputfmt import OutputFormat
120
+ from module_harness.registry import HarnessRegistry
121
+
122
+ # ── harness 组件:HarnessConfig 数据类常量 ─────────────────────────────
123
+ # prompt_core 中的 {message} 占位符由 tasklist inputs 的 {spec.message} 运行时填充。
124
+ HELLO_HARNESS = HarnessConfig(
125
+ name="hello_llm",
126
+ prompt_core="将以下消息改写为正式的英文:{message}",
127
+ output_format=OutputFormat(type="text"),
128
+ temperature=0.3,
129
+ )
130
+
131
+
132
+ # ── script 组件:纯 Python 函数,注册后成为图节点 ──────────────────────
133
+ # 签名固定为 fn(view):view.spec.value 读 spec(仅翻译器上下文),
134
+ # view.<节点名>.value 读上游节点输出(runner 上下文)。
135
+ def echo(view: Any) -> dict[str, Any]:
136
+ """回显上游 Translate 节点的输出(script 消费节点输出)。"""
137
+ return {"message": view.Translate.value}
138
+
139
+
140
+ # ── 模板组件:TasklistTemplate dict(translation + tasklist)──────────
141
+ # translation 是注册在 registry 里的 script 节点,收 view 返回 {Tasks, Flow}。
142
+ def _tl_hello(view: Any) -> dict[str, Any]:
143
+ return {
144
+ "Tasks": {
145
+ "Translate": {
146
+ "type": "harness",
147
+ "harness": "hello_llm",
148
+ "inputs": {"message": "{spec.message}"},
149
+ },
150
+ "Echo": {
151
+ "type": "script",
152
+ "script": "echo",
153
+ "inputs": {"data": "Translate"},
154
+ },
155
+ },
156
+ "Flow": "Translate --> Echo",
157
+ }
158
+
159
+
160
+ HELLO_TEMPLATE: dict[str, Any] = {
161
+ "name": "hello",
162
+ "description": "harness 读入 spec 的 message → script 回显(--mock 可冒烟)",
163
+ "translation": {"type": "script", "script": "tl_hello"},
164
+ "tasklist": {
165
+ "Tasks": {
166
+ "Translate": {
167
+ "type": "harness",
168
+ "harness": "hello_llm",
169
+ "inputs": {"message": "{spec.message}"},
170
+ },
171
+ "Echo": {
172
+ "type": "script",
173
+ "script": "echo",
174
+ "inputs": {"data": "Translate"},
175
+ },
176
+ },
177
+ "Flow": "Translate --> Echo",
178
+ },
179
+ }
180
+
181
+
182
+ # ── registry 构建:注册本模块全部组件(翻译器 + harness + script)──────
183
+ def _build_registry(
184
+ llm_client: Any, template_name: str, event_bus: EventBus
185
+ ) -> HarnessRegistry:
186
+ reg = HarnessRegistry(llm_client=llm_client, event_bus=event_bus)
187
+ reg.harness("hello_llm", HELLO_HARNESS)
188
+ reg.script("echo")(echo)
189
+ reg.script("tl_hello")(_tl_hello)
190
+ return reg
191
+
192
+
193
+ # ── 入口声明:discover_modules() 扫描 modules/*.py 找这个变量 ──────────
194
+ entry = ModuleEntry(
195
+ name="{name}",
196
+ description=__DESCRIPTION__,
197
+ templates={"hello": HELLO_TEMPLATE},
198
+ build_registry=_build_registry,
199
+ default_template="hello",
200
+ default_spec={"message": "你好,世界"},
201
+ spec_schema={"message": "str"},
202
+ review_harness=None, # 固定流程骨架模板;需要一致性审核时改回 "spec_tasklist_review"
203
+ )
204
+ '''
205
+
206
+
207
+ @dataclass
208
+ class ScaffoldResult:
209
+ """init 结果:本次创建/跳过的文件路径清单。"""
210
+
211
+ created: list[Path] = field(default_factory=list)
212
+ skipped: list[Path] = field(default_factory=list)
213
+
214
+
215
+ def _write_if_missing(path: Path, content: str, result: ScaffoldResult) -> None:
216
+ """缺啥补啥:已存在则跳过(不覆盖),否则写入并记入 created。"""
217
+ if path.exists():
218
+ result.skipped.append(path)
219
+ return
220
+ path.parent.mkdir(parents=True, exist_ok=True)
221
+ path.write_text(content, encoding="utf-8")
222
+ result.created.append(path)
223
+
224
+
225
+ def build_module_source(name: str, description: str = "") -> str:
226
+ """渲染 modules/<name>.py 源码(name 内插 + description 安全嵌入为字面量)。"""
227
+ desc = description or f"{name} — 脚手架生成的示例模块"
228
+ return MODULE_TEMPLATE.replace("{name}", name).replace(
229
+ "__DESCRIPTION__", json.dumps(desc, ensure_ascii=False)
230
+ )
231
+
232
+
233
+ def scaffold(
234
+ name: str,
235
+ *,
236
+ base_dir: str | Path = ".",
237
+ force: bool = False,
238
+ description: str = "",
239
+ ) -> ScaffoldResult:
240
+ """生成模块骨架 + 项目文件缺啥补啥。
241
+
242
+ - 模块名非法 → ValueError(CLI 据此退出码 1,零文件生成)。
243
+ - 模块文件已存在且未 ``force`` → ValueError。
244
+ - ``force`` 仅覆盖模块文件;项目文件永不覆盖(幂等)。
245
+ """
246
+ if not validate_module_name(name):
247
+ raise ValueError(
248
+ f"模块名 '{name}' 不是合法 Python 标识符"
249
+ "(须匹配 ^[A-Za-z_][A-Za-z0-9_]*$)"
250
+ )
251
+ base = Path(base_dir)
252
+ result = ScaffoldResult()
253
+
254
+ # 模块文件是本次生成的主角:--force 才覆盖,否则报错。
255
+ module_path = base / "modules" / f"{name}.py"
256
+ if module_path.exists() and not force:
257
+ raise ValueError(f"模块文件已存在: {module_path}(用 --force 覆盖)")
258
+ module_path.parent.mkdir(parents=True, exist_ok=True)
259
+ module_path.write_text(build_module_source(name, description), encoding="utf-8")
260
+ result.created.append(module_path)
261
+
262
+ # 项目文件缺啥补啥(幂等,永不覆盖)。
263
+ _write_if_missing(base / "config.json", json.dumps(CONFIG_JSON, ensure_ascii=False, indent=2) + "\n", result)
264
+ _write_if_missing(base / ".env.example", ENV_EXAMPLE, result)
265
+ _write_if_missing(base / ".gitignore", GITIGNORE, result)
266
+ _write_if_missing(
267
+ base / "spec.example.json",
268
+ json.dumps(SPEC_EXAMPLE_JSON, ensure_ascii=False, indent=2) + "\n",
269
+ result,
270
+ )
271
+ _write_if_missing(
272
+ base / "README.md",
273
+ README_MD.replace("{name}", name),
274
+ result,
275
+ )
276
+ return result
277
+
278
+
279
+ # ── 目录形态(init --dir):与已装模块同构的 pack 目录骨架 ─────────────
280
+
281
+ DIR_MODULE_JSON: dict[str, Any] = {
282
+ "name": "__NAME__",
283
+ "version": "0.1.0",
284
+ "description": "__DESCRIPTION__",
285
+ "submodule": True,
286
+ "spec_schema": {"input": {"message": "str"}, "output": {"message": "str"}},
287
+ "requires": [],
288
+ "modules": [],
289
+ "tasklist": {
290
+ "Tasks": {
291
+ "Greet": {
292
+ "type": "script",
293
+ "script": "greet",
294
+ },
295
+ },
296
+ "Flow": "[Greet]",
297
+ },
298
+ }
299
+
300
+ DIR_SCRIPT_TEMPLATE = '''\
301
+ """{name} 脚本组件:纯 Python 函数,注册后成为图节点。"""
302
+
303
+ from __future__ import annotations
304
+
305
+
306
+ def greet(view):
307
+ """回显上游输入(tasklist 固定:无上游依赖,直接输出)。"""
308
+ return {{"message": "hello from {name}"}}
309
+ '''
310
+
311
+ DIR_HARNESS_EXAMPLE = '''\
312
+ """{name} harness 组件示例(JSON 文件):LLM 调用节点,三层 prompt。
313
+
314
+ 复制为 ``harnesses/<名>.json`` 并在 module.json 的 tasklist 中引用:
315
+ {{
316
+ "type": "harness",
317
+ "harness": "<名>",
318
+ "inputs": {{"text": "{{{{spec.message}}}}"}},
319
+ "outputformat": {{"type": "text"}}
320
+ }}
321
+ """
322
+
323
+ '''
324
+
325
+ DIR_README = """# {name}
326
+
327
+ 由 `specmodule init --dir {name}` 生成的目录形态模块骨架。
328
+
329
+ 与已装模块同构(pack 格式):`module.json` 声明 spec_schema/tasklist,
330
+ `scripts/` 放 Python 函数,`harnesses/` 放 LLM 调用配置(JSON),
331
+ `commands/` 放 shell 命令配置。
332
+
333
+ 运行:
334
+
335
+ ```bash
336
+ specmodule run --module {name} --spec '{{"message": "hi"}}' --mock
337
+ specmodule publish {name} --from . --dir # 或直接 install 本目录
338
+ ```
339
+ """
340
+
341
+
342
+ def scaffold_dir(
343
+ name: str,
344
+ *,
345
+ base_dir: str | Path = ".",
346
+ force: bool = False,
347
+ description: str = "",
348
+ ) -> ScaffoldResult:
349
+ """生成目录形态模块骨架(--dir):pack 同构目录 + 项目文件缺啥补啥。
350
+
351
+ 与 ``scaffold``(单文件)同语义:模块名非法 → ValueError;模块目录
352
+ 已存在且未 force → ValueError;force 仅覆盖模块目录,项目文件永不覆盖。
353
+ """
354
+ if not validate_module_name(name):
355
+ raise ValueError(
356
+ f"模块名 '{name}' 不是合法 Python 标识符"
357
+ "(须匹配 ^[A-Za-z_][A-Za-z0-9_]*$)"
358
+ )
359
+ base = Path(base_dir)
360
+ result = ScaffoldResult()
361
+ mod_dir = base / "modules" / name
362
+ if mod_dir.exists() and not force:
363
+ raise ValueError(f"模块目录已存在: {mod_dir}(用 --force 覆盖)")
364
+ mod_dir.mkdir(parents=True, exist_ok=True)
365
+ result.created.append(mod_dir)
366
+
367
+ manifest = dict(DIR_MODULE_JSON)
368
+ manifest["name"] = name
369
+ manifest["description"] = description or f"{name} — 脚手架生成的示例模块"
370
+ _write_if_missing(
371
+ mod_dir / "module.json",
372
+ json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
373
+ result,
374
+ )
375
+ for sub in ("scripts", "harnesses", "commands", "guards", "submodules"):
376
+ d = mod_dir / sub
377
+ d.mkdir(exist_ok=True)
378
+ result.created.append(d)
379
+ _write_if_missing(
380
+ mod_dir / "scripts" / "greet.py",
381
+ DIR_SCRIPT_TEMPLATE.replace("{name}", name),
382
+ result,
383
+ )
384
+ _write_if_missing(
385
+ mod_dir / "harnesses" / "README.txt",
386
+ DIR_HARNESS_EXAMPLE.replace("{name}", name),
387
+ result,
388
+ )
389
+
390
+ # 项目文件缺啥补啥(幂等,永不覆盖)——与单文件形态共用。
391
+ _write_if_missing(base / "config.json", json.dumps(CONFIG_JSON, ensure_ascii=False, indent=2) + "\n", result)
392
+ _write_if_missing(base / ".env.example", ENV_EXAMPLE, result)
393
+ _write_if_missing(base / ".gitignore", GITIGNORE, result)
394
+ _write_if_missing(
395
+ base / "spec.example.json",
396
+ json.dumps(SPEC_EXAMPLE_JSON, ensure_ascii=False, indent=2) + "\n",
397
+ result,
398
+ )
399
+ _write_if_missing(
400
+ base / "README.md",
401
+ DIR_README.replace("{name}", name),
402
+ result,
403
+ )
404
+ return result
module_harness/spec.py ADDED
@@ -0,0 +1,209 @@
1
+ # module_harness/spec.py
2
+ """Spec 与 Tasklist 数据模型。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import dataclasses
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Literal
9
+
10
+
11
+ class Spec:
12
+ """结构化 spec,用户自由定义字段的键值对集合。"""
13
+
14
+ def __init__(self, data: dict[str, Any]) -> None:
15
+ self._data = dict(data)
16
+
17
+ def __getitem__(self, key: str) -> Any:
18
+ return self._data[key]
19
+
20
+ def get(self, key: str, default: Any = None) -> Any:
21
+ return self._data.get(key, default)
22
+
23
+ def keys(self):
24
+ return self._data.keys()
25
+
26
+ def values(self):
27
+ return self._data.values()
28
+
29
+ def items(self):
30
+ return self._data.items()
31
+
32
+ def __len__(self) -> int:
33
+ return len(self._data)
34
+
35
+ def __contains__(self, key: str) -> bool:
36
+ return key in self._data
37
+
38
+ def __repr__(self) -> str:
39
+ return f"Spec({self._data!r})"
40
+
41
+ def to_dict(self) -> dict[str, Any]:
42
+ return dict(self._data)
43
+
44
+
45
+ @dataclass
46
+ class TaskDefinition:
47
+ """tasklist 中单个 Task 的定义。与 HarnessConfig 字段对齐。"""
48
+
49
+ type: Literal["harness", "script", "command", "submodule"]
50
+ harness: str | None = None
51
+ script: str | None = None
52
+ command: str | None = None # type="command" 时引用的命令名
53
+ submodule: str | None = None # type="submodule" 时引用名(父模块 modules 解析)
54
+ outputs: dict[str, str] | None = None # submodule 输出映射 {节点字段: 子输出字段};缺省 = 全量
55
+ timeout: float | None = None # command 超时覆盖(秒)
56
+ cwd: str | None = None # command 工作目录覆盖
57
+ promptmode: str | None = None
58
+ prompt: str | None = None
59
+ outputformat: dict[str, Any] | None = None
60
+ notdo: list[str] | None = None
61
+ model: str | None = None
62
+ temperature: float | None = None
63
+ think: bool | dict | None = None
64
+ api_params: dict[str, Any] | None = None # 透传给 LLM SDK 的额外参数
65
+ inputs: dict[str, str] | None = None
66
+
67
+ @classmethod
68
+ def from_dict(cls, d: dict[str, Any]) -> "TaskDefinition":
69
+ return cls(
70
+ type=d["type"],
71
+ harness=d.get("harness"),
72
+ script=d.get("script"),
73
+ command=d.get("command"),
74
+ submodule=d.get("submodule"),
75
+ outputs=d.get("outputs"),
76
+ timeout=d.get("timeout"),
77
+ cwd=d.get("cwd"),
78
+ promptmode=d.get("promptmode"),
79
+ prompt=d.get("prompt"),
80
+ outputformat=d.get("outputformat"),
81
+ notdo=d.get("notdo"),
82
+ model=d.get("model"),
83
+ temperature=d.get("temperature"),
84
+ think=d.get("think"),
85
+ api_params=d.get("api_params"),
86
+ inputs=d.get("inputs"),
87
+ )
88
+
89
+
90
+ @dataclass
91
+ class Tasklist:
92
+ """完整的 tasklist:Tasks + Flow。"""
93
+
94
+ tasks: dict[str, TaskDefinition]
95
+ flow: str
96
+
97
+ @classmethod
98
+ def from_json(cls, data: dict[str, Any]) -> "Tasklist":
99
+ if "Tasks" not in data:
100
+ raise ValueError("tasklist 缺少 'Tasks' 字段")
101
+ if "Flow" not in data:
102
+ raise ValueError("tasklist 缺少 'Flow' 字段")
103
+ tasks = {
104
+ key: TaskDefinition.from_dict(td)
105
+ for key, td in data["Tasks"].items()
106
+ }
107
+ return cls(tasks=tasks, flow=data["Flow"])
108
+
109
+ def to_dict(self) -> dict[str, Any]:
110
+ """JSON 可序列化 dict(与 ``from_json`` 对称)——唯一实现(S4)。"""
111
+ return {
112
+ "Tasks": {k: dataclasses.asdict(v) for k, v in self.tasks.items()},
113
+ "Flow": self.flow,
114
+ }
115
+
116
+
117
+ @dataclass
118
+ class TranslationSpec:
119
+ """翻译方式声明。"""
120
+
121
+ type: Literal["harness", "script"]
122
+ harness: str | None = None
123
+ script: str | None = None
124
+ prompt: str | None = None
125
+ prompt_core: str | None = None
126
+
127
+ @classmethod
128
+ def from_dict(cls, d: dict[str, Any]) -> "TranslationSpec":
129
+ return cls(
130
+ type=d["type"],
131
+ harness=d.get("harness"),
132
+ script=d.get("script"),
133
+ prompt=d.get("prompt"),
134
+ prompt_core=d.get("prompt_core"),
135
+ )
136
+
137
+
138
+ @dataclass
139
+ class TasklistTemplate:
140
+ """tasklist 模板 = 翻译声明 + tasklist 骨架。"""
141
+
142
+ name: str
143
+ description: str
144
+ translation: TranslationSpec
145
+ tasklist: Tasklist
146
+
147
+ @classmethod
148
+ def from_json(cls, data: dict[str, Any]) -> "TasklistTemplate":
149
+ if "name" not in data:
150
+ raise ValueError("模板缺少 'name' 字段")
151
+ return cls(
152
+ name=data["name"],
153
+ description=data.get("description", ""),
154
+ translation=TranslationSpec.from_dict(data["translation"]),
155
+ tasklist=Tasklist.from_json(data["tasklist"]),
156
+ )
157
+
158
+
159
+ _SCHEMA_TYPES: dict[str, type] = {
160
+ "str": str, "int": int, "float": float, "bool": bool,
161
+ "list": list, "dict": dict,
162
+ }
163
+
164
+
165
+ def _value_matches(value: Any, type_name: str) -> bool:
166
+ """判断值是否满足类型声明。bool 与 int 严格区分。"""
167
+ if type_name == "any":
168
+ return True
169
+ expected = _SCHEMA_TYPES.get(type_name)
170
+ if expected is None:
171
+ return False
172
+ if expected is bool:
173
+ return isinstance(value, bool)
174
+ if expected is int:
175
+ return isinstance(value, int) and not isinstance(value, bool)
176
+ return isinstance(value, expected)
177
+
178
+
179
+ @dataclass
180
+ class SpecSchema:
181
+ """submodule 的 spec 契约:input 校验,output 仅声明。"""
182
+
183
+ input: dict[str, str] = field(default_factory=dict)
184
+ output: dict[str, str] = field(default_factory=dict)
185
+
186
+ def validate(self, spec: dict[str, Any]) -> list[str]:
187
+ """校验 spec 是否满足契约。返回错误列表,空 = 通过。
188
+
189
+ 声明的字段必须存在且类型匹配;未声明的字段允许存在。
190
+ """
191
+ errors: list[str] = []
192
+ for field_name, type_name in self.input.items():
193
+ if field_name not in spec:
194
+ errors.append(f"缺少字段 '{field_name}'(应为 {type_name})")
195
+ continue
196
+ if not _value_matches(spec[field_name], type_name):
197
+ errors.append(
198
+ f"字段 '{field_name}' 类型错误:期望 {type_name},"
199
+ f"实际 {type(spec[field_name]).__name__}"
200
+ )
201
+ return errors
202
+
203
+
204
+ class SpecValidationError(Exception):
205
+ """spec 不满足 spec_schema 契约。"""
206
+
207
+ def __init__(self, errors: list[str]) -> None:
208
+ self.errors = errors
209
+ super().__init__("spec 校验失败:\n" + "\n".join(f" - {e}" for e in errors))