muse-agent-example/工具/构建资源包.py
zizi b86516505f W02 安装工程、启动骨架与验证环境:pyproject/uv 安装工程、启动装配骨架、conftest 用例身份与 OS 离线隔离。
按 R2 串行阶段整理提交;包内文件为该阶段交付(含后续小增量),状态以工作包清单为准。
2026-09-10 19:25:40 +08:00

186 lines
7.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.

"""生成运行资源与发布清单。
工程阶段的生成或预检入口:
- 从唯一登记输入(工具/资源登记.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(主())