feat: 完成 issue #85 [树脂] 树脂模板打包与版本发布
This commit is contained in:
@@ -72,11 +72,11 @@ def main() -> int:
|
|||||||
with open(path, "r", encoding="utf-8") as fh:
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
yaml.safe_load(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:
|
with open(os.path.join(HERE, "version.yaml"), "r", encoding="utf-8") as fh:
|
||||||
version = yaml.safe_load(fh)
|
version = yaml.safe_load(fh)
|
||||||
for rel in version.get("assets", []):
|
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}")
|
failures.append(f"version.yaml 声明资产缺失:{rel}")
|
||||||
|
|
||||||
# 4) 驾驶舱布局 widget 类型合法
|
# 4) 驾驶舱布局 widget 类型合法
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
@@ -15,7 +15,11 @@ version: 0.1.0
|
|||||||
assets:
|
assets:
|
||||||
- point-dict/point_dict.resin.csv
|
- point-dict/point_dict.resin.csv
|
||||||
- rag-kb/kb.resin.template.yaml
|
- rag-kb/kb.resin.template.yaml
|
||||||
|
- rag-kb/loader.py
|
||||||
|
- rag-kb/documents/
|
||||||
- dashboard/cockpit.resin.yaml
|
- dashboard/cockpit.resin.yaml
|
||||||
|
- README.md
|
||||||
notes:
|
notes:
|
||||||
- 点位字典取自已交付平台采集层(和利时DCS/威盛DCS/西门子S7-1200/称重/能源/人工录入)
|
- 点位字典取自已交付平台采集层(和利时DCS/威盛DCS/西门子S7-1200/称重/能源/人工录入)
|
||||||
- 驾驶舱布局对齐 PRD 5.5 iAOP-cockpit-layout-v1 schema
|
- 驾驶舱布局对齐 PRD 5.5 iAOP-cockpit-layout-v1 schema
|
||||||
|
- RAG 知识库文档集与加载器(issue #83)随模板发布
|
||||||
|
|||||||
Reference in New Issue
Block a user