muse-agent-example/工具/生成接口.py
zizi 0658d178ad 工程底座:单一生成入口、验证期写闸门与并行分片
- Makefile:新增 验收数据库(生成→检查→库层全量,前置校验隔离库连接串)、数据库分片、浏览器测试三个入口;
  格式写入 末尾就地重新生成;数据库测试 与 验收数据库 统一排除 浏览器/网络;快检 不再静默跳过类型门;
  前端旅程 预检补齐四个必需变量;pytest 目标改用仓内解释器(uv run 在嵌套检出会解析到外层环境)。
- 工具/验证锁.py:验证会话持共享锁,写入口用 --执行 在独占锁内落盘,生成/格式写入/索引生成走同一闸门。
- 工具/并行数据库测试.py:按文件分片并行,缺连接串在采集前拒绝。
- 工具/构建编排.py、环境预检.py、构建资源包.py、维护索引.py 与上述口径对齐。
- .gitignore / CI / README / AGENTS:忽略构建产物、CI 与 Makefile 单一口径、README 按实际实现陈述、AGENTS 补日常入口与验证纪律。
2026-09-18 01:14:50 +08:00

157 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""从登记路由工厂生成 OpenAPI 与客户端外壳;类型由后续步骤生成。
先核对全部登记与输出位置,再导入工厂并生成内存合同,最后写入。
不启动应用、不连接数据库或读取凭据。
"""
from __future__ import annotations
import argparse
import importlib
import json
import keyword
import sys
from collections.abc import Callable
from pathlib import Path
from typing import Any
仓库根 = Path(__file__).resolve().parent.parent
登记文件 = 仓库根 / "工具" / "生成输入.json"
_客户端路径 = "web/src/接口/生成/客户端.ts"
_客户端正文 = (
"// 由工具/生成接口.py生成;稳定外壳来自同一OpenAPI。\n"
'import createClient from "openapi-fetch";\n'
'import type { paths } from "./类型";\n'
'import { 传输 } from "../传输";\n\n'
"export const 客户端 = createClient<paths>({\n"
" baseUrl: globalThis.location.origin, fetch: 传输,\n"
' credentials: "same-origin"\n'
"});\n"
)
class 生成错误(ValueError):
"""仅携带可安全展示的登记或合同错误,不包含原始工厂异常。"""
def 已登记输入() -> dict[str, Any]:
"""缺登记与坏登记都拒绝;显式空资源表仍是合法的无输出登记。"""
try:
数据 = json.loads(登记文件.read_text(encoding="utf-8"))
except FileNotFoundError:
raise 生成错误("生成输入登记不存在") from None
except (OSError, UnicodeError, ValueError):
raise 生成错误("生成输入登记无法读取或不是合法 JSON") from None
if (
not isinstance(数据, dict)
or type(数据.get("schema_version")) is not int
or 数据.get("schema_version") != 1
or not isinstance(数据.get("资源"), list)
):
raise 生成错误("生成输入登记需要 schema_version=1 和资源列表")
return 数据
def _输出位置(地址: Any) -> Path:
if not isinstance(地址, str) or not 地址.strip() or "\\" in 地址:
raise 生成错误("输出必须为仓库内相对文件路径")
路径 = Path(地址)
if 路径.is_absolute() or ".." in 路径.parts or 路径 == Path("."):
raise 生成错误("输出拒绝绝对路径、父目录跳转或仓库根目录")
try:
输出 = (仓库根 / 路径).resolve()
except (OSError, ValueError, RuntimeError):
raise 生成错误("输出路径无法解析") from None
if not 输出.is_relative_to(仓库根.resolve()) or 输出 == 仓库根.resolve():
raise 生成错误("输出经软链接解析后超出仓库范围")
if 输出.is_dir() or any(p.exists() and not p.is_dir() for p in 输出.parents):
raise 生成错误("输出位置被现有目录或非目录父路径占用")
return 输出
def 预检输入(输入: dict[str, Any]) -> list[tuple[str, str, Path]]:
"""所有条目在导入或写入前校验;固定客户端输出同样不能逃逸。"""
if (
not isinstance(输入, dict)
or type(输入.get("schema_version")) is not int
or 输入.get("schema_version") != 1
or not isinstance(输入.get("资源"), list)
):
raise 生成错误("生成输入登记需要 schema_version=1 和资源列表")
计划 = []
目标集 = {_输出位置(_客户端路径)} if 输入["资源"] else set()
for 序, 条目 in enumerate(输入["资源"], 1):
if not isinstance(条目, dict) or 条目.get("格式") != "OpenAPI":
raise 生成错误(f"第 {序} 项必须是 OpenAPI 生成登记")
工厂 = 条目.get("工厂")
if not isinstance(工厂, str) or 工厂.count(":") != 1:
raise 生成错误(f"第 {序} 项工厂必须使用 module:symbol 格式")
模块, 名称 = 工厂.split(":")
if not all(p.isidentifier() and not keyword.iskeyword(p) for p in [*模块.split("."), 名称]):
raise 生成错误(f"第 {序} 项工厂必须使用合法 module:symbol 格式")
输出 = _输出位置(条目.get("输出"))
if any(输出 == p or 输出 in p.parents or p in 输出.parents for p in 目标集):
raise 生成错误(f"第 {序} 项输出与其它产物重复或父子路径冲突")
目标集.add(输出)
计划.append((模块, 名称, 输出))
return 计划
def 生成接口(输入: dict[str, Any]) -> list[Path]:
计划 = 预检输入(输入)
工厂组: list[tuple[Callable, Path]] = []
for 序, (模块, 名称, 输出) in enumerate(计划, 1):
try:
工厂 = getattr(importlib.import_module(模块), 名称)
except Exception:
raise 生成错误(f"第 {序} 项工厂无法导入") from None
if not callable(工厂):
raise 生成错误(f"第 {序} 项工厂不是可调用对象")
工厂组.append((工厂, 输出))
产物 = {}
for 序, (工厂, 输出) in enumerate(工厂组, 1):
try:
合同 = 工厂()
except Exception:
raise 生成错误(f"第 {序} 项工厂执行失败") from None
if (
not isinstance(合同, dict)
or not isinstance(合同.get("openapi"), str)
or not 合同["openapi"].strip()
):
raise 生成错误(f"第 {序} 项工厂未返回 OpenAPI 合同")
try:
产物[输出] = json.dumps(合同, ensure_ascii=False, indent=2, allow_nan=False) + "\n"
except (TypeError, ValueError):
raise 生成错误(f"第 {序} 项合同无法序列化") from None
if 产物:
产物[_输出位置(_客户端路径)] = _客户端正文
for 输出, 正文 in 产物.items():
输出.parent.mkdir(parents=True, exist_ok=True)
输出.write_text(正文, encoding="utf-8")
return list(产物)
def 主() -> int:
解析器 = argparse.ArgumentParser(
description="生成 OpenAPI 与客户端外壳;类型.ts 由后续 openapi-typescript 步骤生成"
)
解析器.parse_args()
try:
产物 = 生成接口(已登记输入())
except 生成错误 as 错误:
print(f"生成接口失败,未生成合同:{错误}", file=sys.stderr)
return 1
except OSError:
print("生成接口失败:无法写入已校验的输出位置", file=sys.stderr)
return 1
if not 产物:
print("生成接口:显式空登记,未生成任何合同")
for 输出 in 产物:
print(f"生成接口:{输出.relative_to(仓库根)}")
return 0
if __name__ == "__main__":
raise SystemExit(主())