186 lines
7.5 KiB
Python
186 lines
7.5 KiB
Python
"""生成运行资源与发布清单。
|
||
|
||
工程阶段的生成或预检入口:
|
||
- 从唯一登记输入(工具/资源登记.json)打包 src/muse/资源/运行资源.zip;
|
||
- 写出 src/muse/资源/清单.json(发布身份 + 每个资源的相对路径与 sha256);
|
||
- 投影 web/dist → src/muse/资源/工作台/(web 构建存在时)。
|
||
|
||
边界:只打包已登记生产资源;不读取未审工作树内容、不打包 tests/.git/当前目录;
|
||
生成物幂等可重现(固定排序、固定压缩时间戳)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import hashlib
|
||
import json
|
||
import shutil
|
||
import tomllib
|
||
import zipfile
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from muse.资源加载 import 解析能力声明
|
||
|
||
仓库根 = Path(__file__).resolve().parent.parent
|
||
登记文件 = 仓库根 / "工具" / "资源登记.json"
|
||
资源目录 = 仓库根 / "src" / "muse" / "资源"
|
||
压缩包名 = "运行资源.zip"
|
||
清单名 = "清单.json"
|
||
_固定时间戳 = (2026, 1, 1, 0, 0, 0)
|
||
|
||
|
||
def 读取登记() -> dict[str, Any]:
|
||
if not 登记文件.is_file():
|
||
raise SystemExit(f"资源登记不存在:{登记文件}(打包前必须先登记生产资源)")
|
||
登记: Any = json.loads(登记文件.read_text(encoding="utf-8"))
|
||
if not isinstance(登记, dict) or "资源" not in 登记:
|
||
raise SystemExit("资源登记不合法:缺少 资源 表")
|
||
return 登记
|
||
|
||
|
||
def _sha256(文件: Path) -> str:
|
||
return hashlib.sha256(文件.read_bytes()).hexdigest()
|
||
|
||
|
||
def 编译能力资源(
|
||
根: Path, 文件清单: list[str], 已实现命令: set[str]
|
||
) -> tuple[dict, dict[str, bytes]]:
|
||
目录: dict[str, dict] = {"role": {}, "method": {}, "operation": {}}
|
||
资源: dict[str, bytes] = {}
|
||
for 相对 in sorted(文件清单):
|
||
文件 = (根 / 相对).resolve()
|
||
if not 文件.is_relative_to(根.resolve()) or not 文件.is_file():
|
||
raise ValueError("能力资源必须是登记根内的实际文件")
|
||
数据 = 文件.read_bytes()
|
||
声明, _ = 解析能力声明(数据)
|
||
分类, 身份 = 声明["category"], 声明["id"]
|
||
if 身份 in 目录[分类]:
|
||
raise ValueError("能力稳定身份重复")
|
||
命令 = 声明.get("commands", [])
|
||
if not isinstance(命令, list) or any(
|
||
not isinstance(v, str) or v not in 已实现命令 for v in 命令
|
||
):
|
||
raise ValueError("能力声明包含未实现命令")
|
||
if 分类 == "operation" and not 命令:
|
||
raise ValueError("操作技能必须声明已实现命令")
|
||
路径 = f"能力/{分类}/{身份}.md"
|
||
资源[路径] = 数据
|
||
目录[分类][身份] = {
|
||
"id": 身份,
|
||
"name": 声明["name"],
|
||
"description": 声明["description"],
|
||
"contract_version": 声明["contract_version"],
|
||
"commands": 命令,
|
||
"resource_path": 路径,
|
||
"sha256": hashlib.sha256(数据).hexdigest(),
|
||
}
|
||
return 目录, 资源
|
||
|
||
|
||
def 打包() -> dict[str, Any]:
|
||
登记 = 读取登记()
|
||
发布身份 = str(登记.get("发布身份", ""))
|
||
if not 发布身份.strip():
|
||
raise SystemExit("资源登记缺少 发布身份")
|
||
条目: list[dict[str, str]] = 登记["资源"]
|
||
能力资源: dict[str, bytes] = {}
|
||
if 登记.get("能力文件"):
|
||
from muse.接入.http.应用 import 导出合同
|
||
|
||
合同 = 导出合同()
|
||
命令 = {
|
||
值["operationId"]
|
||
for 路径 in 合同["paths"].values()
|
||
for 值 in 路径.values()
|
||
if "operationId" in 值
|
||
}
|
||
目录, 能力资源 = 编译能力资源(仓库根, 登记["能力文件"], 命令)
|
||
能力资源["能力/目录.json"] = json.dumps(
|
||
目录, ensure_ascii=False, sort_keys=True, indent=2
|
||
).encode()
|
||
资源目录.mkdir(parents=True, exist_ok=True)
|
||
压缩包路径 = 资源目录 / 压缩包名
|
||
|
||
已打包: dict[str, str] = {}
|
||
with zipfile.ZipFile(压缩包路径, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||
for 条 in sorted(条目, key=lambda x: str(x.get("打包路径", ""))):
|
||
源 = 仓库根 / str(条["源路径"])
|
||
打包路径 = str(条["打包路径"])
|
||
if not 源.is_file():
|
||
raise SystemExit(f"登记资源不存在:{源}")
|
||
信息 = zipfile.ZipInfo(打包路径, date_time=_固定时间戳)
|
||
信息.compress_type = zipfile.ZIP_DEFLATED
|
||
信息.external_attr = 0o644 << 16
|
||
zf.writestr(信息, 源.read_bytes())
|
||
已打包[打包路径] = _sha256(源)
|
||
for 路径, 数据 in sorted(能力资源.items()):
|
||
if 路径 in 已打包:
|
||
raise ValueError("生成能力资源与直接登记路径重复")
|
||
信息 = zipfile.ZipInfo(路径, date_time=_固定时间戳)
|
||
信息.compress_type = zipfile.ZIP_DEFLATED
|
||
信息.external_attr = 0o644 << 16
|
||
zf.writestr(信息, 数据)
|
||
已打包[路径] = hashlib.sha256(数据).hexdigest()
|
||
|
||
# 可编辑安装和正式 wheel 使用相同的包内迁移入口。
|
||
迁移源 = 登记.get("迁移目录")
|
||
if 迁移源:
|
||
源目录 = 仓库根 / 迁移源
|
||
if not 源目录.is_dir():
|
||
raise SystemExit("登记的迁移目录不存在")
|
||
迁移资源 = 资源目录 / "迁移"
|
||
if 迁移资源.exists():
|
||
shutil.rmtree(迁移资源)
|
||
迁移资源.mkdir()
|
||
for 文件 in sorted(源目录.glob("V*.sql")):
|
||
shutil.copyfile(文件, 迁移资源 / 文件.name)
|
||
已打包[f"迁移/{文件.name}"] = _sha256(文件)
|
||
|
||
# 工作台投影:web 构建产物存在时带上;不存在时如实留空。
|
||
工作台目录 = 资源目录 / "工作台"
|
||
if 工作台目录.exists():
|
||
shutil.rmtree(工作台目录)
|
||
web_dist = 仓库根 / "web" / "dist"
|
||
if web_dist.is_dir():
|
||
shutil.copytree(web_dist, 工作台目录)
|
||
for 文件 in sorted(工作台目录.rglob("*")):
|
||
if 文件.is_file():
|
||
相对 = 文件.relative_to(资源目录).as_posix()
|
||
已打包[相对] = _sha256(文件)
|
||
|
||
清单 = {
|
||
"schema_version": 1,
|
||
"发布身份": 发布身份,
|
||
"资源": {路径: {"sha256": 哈希} for 路径, 哈希 in sorted(已打包.items())},
|
||
}
|
||
代码文件 = {
|
||
文件.relative_to(仓库根).as_posix(): _sha256(文件)
|
||
for 文件 in (仓库根 / "src/muse").rglob("*")
|
||
if 文件.suffix in {".py", ".ts"}
|
||
and "资源" not in 文件.relative_to(仓库根 / "src/muse").parts
|
||
}
|
||
代码哈希 = hashlib.sha256(json.dumps(代码文件, sort_keys=True).encode()).hexdigest()
|
||
with (仓库根 / "pyproject.toml").open("rb") as 文件:
|
||
包版本 = tomllib.load(文件)["project"]["version"]
|
||
清单.update({"代码版本": 包版本, "代码哈希": 代码哈希, "合同版本": 1})
|
||
清单["构建身份"] = hashlib.sha256(
|
||
json.dumps(清单, ensure_ascii=False, sort_keys=True).encode()
|
||
).hexdigest()
|
||
(资源目录 / 清单名).write_text(
|
||
json.dumps(清单, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||
)
|
||
return 清单
|
||
|
||
|
||
def 主() -> int:
|
||
解析器 = argparse.ArgumentParser(description="生成运行资源与发布清单")
|
||
解析器.parse_args()
|
||
清单 = 打包()
|
||
print(f"构建资源包:发布身份 {清单['发布身份']},资源 {len(清单['资源'])} 项")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(主())
|