diff --git a/templates/resin/_sanity_check.py b/templates/resin/_sanity_check.py index 21010fc..70c1428 100644 --- a/templates/resin/_sanity_check.py +++ b/templates/resin/_sanity_check.py @@ -72,11 +72,11 @@ def main() -> int: with open(path, "r", encoding="utf-8") as fh: yaml.safe_load(fh) - # 3) version.yaml assets 清单存在 + # 3) version.yaml assets 清单存在(支持文件与目录条目) with open(os.path.join(HERE, "version.yaml"), "r", encoding="utf-8") as fh: version = yaml.safe_load(fh) for rel in version.get("assets", []): - if not os.path.isfile(os.path.join(HERE, rel)): + if not os.path.exists(os.path.join(HERE, rel)): failures.append(f"version.yaml 声明资产缺失:{rel}") # 4) 驾驶舱布局 widget 类型合法 diff --git a/templates/resin/template_packaging.py b/templates/resin/template_packaging.py new file mode 100644 index 0000000..689ffa0 --- /dev/null +++ b/templates/resin/template_packaging.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +"""树脂模板打包与版本发布 —— issue #85(父 Issue #13 子任务)。 + +按 `version.yaml`(#13)的 assets 清单把 iAOP-Template-Resin 打包发布: + +- `validate_version()`:semver 校验 + assets 清单文件/目录存在性检查 + (发布前门禁,防止打包不完整); +- `package_template()`:按清单打包 zip(含 version.yaml + README), + 输出 `dist/resin-{version}.zip`; +- `render_release_notes()`:从 version.yaml 生成 Markdown 发布说明 + (版本/行业/基线/资产清单/备注),供仓库 Release 使用。 + +纯标准库实现(zipfile),无第三方依赖。 +""" +from __future__ import annotations + +import os +import re +import zipfile +from typing import List, Optional + +import yaml + +#: 模板根目录(相对本模块) +TEMPLATE_ROOT = os.path.dirname(os.path.abspath(__file__)) +#: 版本清单(与 #13 同构) +VERSION_PATH = os.path.join(TEMPLATE_ROOT, "version.yaml") + +#: semver 校验(x.y.z) +_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$") + +#: 打包时始终包含的清单外文件 +_ALWAYS_INCLUDE = ["version.yaml", "README.md"] + + +def load_version(path: str = VERSION_PATH) -> dict: + """加载版本清单。""" + with open(path, "r", encoding="utf-8") as fh: + return yaml.safe_load(fh) or {} + + +def validate_version(path: str = VERSION_PATH) -> List[str]: + """版本清单校验:semver 合法 + assets 文件/目录存在。 + + Returns: + 问题列表(空 = 校验通过)。 + """ + problems: List[str] = [] + version = load_version(path) + ver = str(version.get("version", "")) + if not _SEMVER_RE.match(ver): + problems.append(f"version 非法(需 semver x.y.z):{ver!r}") + for rel in version.get("assets", []): + full = os.path.join(TEMPLATE_ROOT, rel) + if not os.path.exists(full): + problems.append(f"资产缺失:{rel}") + return problems + + +def _add_entry(zf: zipfile.ZipFile, rel: str) -> None: + """把单个文件或目录加入 zip(保留相对路径)。""" + full = os.path.join(TEMPLATE_ROOT, rel) + if os.path.isdir(full): + for root, _, files in os.walk(full): + for name in files: + abs_path = os.path.join(root, name) + zf.write(abs_path, os.path.relpath(abs_path, TEMPLATE_ROOT)) + else: + zf.write(full, rel) + + +def package_template(path: str = VERSION_PATH, + output_dir: Optional[str] = None) -> str: + """按 assets 清单打包模板为 zip。 + + Returns: + 生成的 zip 文件路径。 + """ + version = load_version(path) + ver = str(version.get("version", "")) + problems = validate_version(path) + if problems: + raise ValueError("打包前校验未通过:" + "; ".join(problems)) + + dist = output_dir or os.path.join(TEMPLATE_ROOT, "dist") + os.makedirs(dist, exist_ok=True) + zip_path = os.path.join(dist, f"resin-{ver}.zip") + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + entries = _ALWAYS_INCLUDE + list(version.get("assets", [])) + for rel in dict.fromkeys(entries): # 去重保序 + _add_entry(zf, rel) + return zip_path + + +def render_release_notes(path: str = VERSION_PATH) -> str: + """从 version.yaml 生成 Markdown 发布说明。""" + version = load_version(path) + lines = [ + f"# iAOP-Template-Resin v{version.get('version', '?')}", + "", + f"- 行业:{version.get('industry', '')}", + f"- 基线:{version.get('baseline', '')}", + f"- 并行:{version.get('parallel_with', '')}", + f"- 状态:{version.get('status', '')}", + "", + "## 资产清单", + ] + lines += [f"- `{a}`" for a in version.get("assets", [])] + notes = version.get("notes", []) + if notes: + lines.append("") + lines.append("## 备注") + lines += [f"- {n}" for n in notes] + return "\n".join(lines) + "\n" diff --git a/templates/resin/tests/test_packaging.py b/templates/resin/tests/test_packaging.py new file mode 100644 index 0000000..964bcae --- /dev/null +++ b/templates/resin/tests/test_packaging.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +"""树脂模板打包与版本发布测试(issue #85)。 + +覆盖: +1. validate_version:semver 合法、assets 清单文件/目录存在; +2. 非法 semver → 问题列表; +3. package_template:按清单打包 zip(含 version.yaml + 资产文件/目录递归); +4. 打包前校验失败(资产缺失)→ ValueError; +5. render_release_notes:发布说明含版本/行业/基线/资产清单。 +""" +import os +import sys +import tempfile +import unittest +import zipfile + +_RESIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, _RESIN_DIR) +from template_packaging import ( # noqa: E402 + TEMPLATE_ROOT, + VERSION_PATH, + package_template, + render_release_notes, + validate_version, +) + + +class TestValidateVersion(unittest.TestCase): + """版本清单校验。""" + + def test_valid_template(self): + self.assertEqual(validate_version(), []) + + def test_invalid_semver(self): + tmp = tempfile.NamedTemporaryFile( + "w", suffix=".yaml", delete=False, encoding="utf-8") + tmp.write("version: abc\nassets: []\n") + tmp.close() + try: + problems = validate_version(tmp.name) + finally: + os.unlink(tmp.name) + self.assertTrue(any("semver" in p for p in problems)) + + def test_missing_asset_reported(self): + tmp = tempfile.NamedTemporaryFile( + "w", suffix=".yaml", delete=False, encoding="utf-8") + tmp.write("version: 1.0.0\nassets: [no/such/file.yaml]\n") + tmp.close() + try: + problems = validate_version(tmp.name) + finally: + os.unlink(tmp.name) + self.assertTrue(any("缺失" in p for p in problems)) + + +class TestPackage(unittest.TestCase): + """打包。""" + + def test_package_zip_contents(self): + with tempfile.TemporaryDirectory() as out: + zip_path = package_template(output_dir=out) + self.assertTrue(os.path.isfile(zip_path)) + self.assertTrue(zip_path.endswith("resin-0.1.0.zip")) + with zipfile.ZipFile(zip_path) as zf: + names = zf.namelist() + # 必含清单与核心资产 + self.assertIn("version.yaml", names) + self.assertIn("point-dict/point_dict.resin.csv", names) + self.assertIn("rag-kb/kb.resin.template.yaml", names) + # 目录递归(documents/ 8 篇) + docs = [n for n in names if n.startswith("rag-kb/documents/")] + self.assertEqual(len(docs), 8) + + def test_package_fails_on_missing_asset(self): + tmp = tempfile.NamedTemporaryFile( + "w", suffix=".yaml", delete=False, encoding="utf-8") + tmp.write("version: 1.0.0\nassets: [no/such/file.yaml]\n") + tmp.close() + try: + with self.assertRaises(ValueError): + package_template(path=tmp.name) + finally: + os.unlink(tmp.name) + + +class TestReleaseNotes(unittest.TestCase): + """发布说明。""" + + def test_release_notes_content(self): + notes = render_release_notes() + self.assertIn("# iAOP-Template-Resin v0.1.0", notes) + self.assertIn("吸附树脂", notes) + self.assertIn("化工新材料AI平台", notes) + self.assertIn("point-dict/point_dict.resin.csv", notes) + self.assertIn("## 资产清单", notes) + + +if __name__ == "__main__": + unittest.main() diff --git a/templates/resin/version.yaml b/templates/resin/version.yaml index 6fc8bda..aaa3802 100644 --- a/templates/resin/version.yaml +++ b/templates/resin/version.yaml @@ -15,7 +15,11 @@ version: 0.1.0 assets: - point-dict/point_dict.resin.csv - rag-kb/kb.resin.template.yaml + - rag-kb/loader.py + - rag-kb/documents/ - dashboard/cockpit.resin.yaml + - README.md notes: - 点位字典取自已交付平台采集层(和利时DCS/威盛DCS/西门子S7-1200/称重/能源/人工录入) - 驾驶舱布局对齐 PRD 5.5 iAOP-cockpit-layout-v1 schema + - RAG 知识库文档集与加载器(issue #83)随模板发布