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