Files
chongming-utils-cli/src/chongming_cli/build_plugin.py
T

176 lines
6.6 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.
"""
进程插件打包器 — 将 external 进程插件开发目录打包为可部署 zip
用法:
uv run build_plugin <plugin_name> [--output <dir>]
示例:
uv run build_plugin demo # 打包 workers/demo → dist/demo.zip
uv run build_plugin demo --output /tmp/pkg
开发目录即产物: workers/<name>/ 是自包含的插件项目根(= 容器工作目录 = zip 根),
打包只做校验 + 直接压缩,不再从仓库多处装配:
workers/<name>/ 插件项目根(zip 根即容器工作目录)
├── workers/<name>/ 插件代码(模块 workers.<name>)
├── config/workers/<name>/ 插件注册 + 端点配置
├── config/zenoh_config.json5 Zenoh 会话配置
├── config.toml 最小根配置(仅 [zenoh] 节,worker 启动必需)
├── pyproject.toml uv 依赖清单(chongming-worker + 扩展依赖)
└── start.sh 容器入口: uv sync + uv run worker_serve --type <name>
说明:
- 打包自动排除 __pycache__ / *.pyc / .venv / *.egg-info / .git
- start.sh 权限强制为 0o755(防止 git 丢执行位导致容器无法启动)
- 新项目骨架用 `uv run gen_plugin <name> --process` 生成
"""
import argparse
import re
import sys
import tomllib
import zipfile
from pathlib import Path
from ._root import find_project_root
NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
# 打包排除的路径模式(目录名 / glob)
EXCLUDE_DIRS = {"__pycache__", ".venv", ".git"}
EXCLUDE_SUFFIXES = (".pyc", ".pyo", ".egg-info")
# 项目根目录(CHONGMING_ROOT 环境变量 > CWD 向上查找,见 _root.py)
ROOT = find_project_root()
WORKERS_DIR = ROOT / "workers"
def _rel(path: Path) -> Path | str:
"""相对 ROOT 的路径(不在 ROOT 下时原样返回,供错误消息使用)。"""
try:
return path.relative_to(ROOT)
except ValueError:
return path
def _check_project_layout(project_dir: Path, name: str) -> dict:
"""
校验插件项目结构完整(运行时必需的文件),返回插件 [workers] 节配置。
Raises:
FileNotFoundError / ValueError: 结构不合法
"""
# 1. 插件注册 + 端点配置(worker 进程自读)
conf_path = project_dir / "config" / "workers" / name / "config.toml"
if not conf_path.exists():
raise FileNotFoundError(
f"未找到插件配置: {conf_path.relative_to(project_dir)}/(缺失的话先运行 "
f"`uv run gen_plugin {name} --process` 生成骨架)"
)
with open(conf_path, "rb") as f:
plugin_conf = tomllib.load(f)
workers = plugin_conf.get("workers")
if not workers:
raise ValueError(f"{conf_path} 缺少 [workers] 节")
# 2. Zenoh 会话配置(worker 启动必需)
if not (project_dir / "config" / "zenoh_config.json5").exists():
raise FileNotFoundError(
f"未找到 Zenoh 配置: {_rel(project_dir / 'config' / 'zenoh_config.json5')}"
)
# 3. 最小根配置(worker_serve 读取 [zenoh] 节打开会话)
root_conf_path = project_dir / "config.toml"
if not root_conf_path.exists():
raise FileNotFoundError(f"未找到最小根配置: {_rel(root_conf_path)}")
with open(root_conf_path, "rb") as f:
root_conf = tomllib.load(f)
if not root_conf.get("zenoh"):
raise ValueError(f"{root_conf_path} 缺少 [zenoh] 节(worker 启动必需)")
return workers
def _zip_project(project_dir: Path, zip_path: Path) -> None:
"""把项目根内容压缩为 zip(zip 根即容器工作目录),排除缓存/环境目录。"""
zip_path.unlink(missing_ok=True)
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for path in sorted(project_dir.rglob("*")):
if not path.is_file():
continue
rel = path.relative_to(project_dir)
if any(part in EXCLUDE_DIRS for part in rel.parts):
continue
if any(part.endswith(EXCLUDE_SUFFIXES) for part in rel.parts):
continue
info = zipfile.ZipInfo.from_file(path, str(rel))
if rel.name == "start.sh":
info.external_attr = 0o100755 << 16 # 强制可执行,防 git 丢执行位
else:
info.external_attr = (path.stat().st_mode & 0xFFFF) << 16
zf.writestr(info, path.read_bytes())
def build_plugin_package(name: str, output_dir: str | Path = "dist") -> Path:
"""
打包进程插件为可部署 zip,返回产物路径。
失败(插件/配置缺失、结构不合法)抛 FileNotFoundError / ValueError。
"""
if not NAME_RE.match(name):
raise ValueError(f"插件名不合法: '{name}'(需小写字母/数字/下划线,且以字母开头)")
project_dir = WORKERS_DIR / name
if not project_dir.is_dir():
raise FileNotFoundError(f"未找到插件项目目录: {_rel(project_dir)}/")
workers = _check_project_layout(project_dir, name)
load_mode = workers.get("load_mode", "inline")
if load_mode != "external":
print(
f"⚠️ [{name}] load_mode = \"{load_mode}\":网关也会在进程内加载该插件,"
"独立进程将与网关内副本重复服务。进程插件请在 [workers] 节设置 "
"load_mode = \"external\"。"
)
output = Path(output_dir)
output.mkdir(parents=True, exist_ok=True)
zip_path = output / f"{name}.zip"
_zip_project(project_dir, zip_path)
return zip_path
def _format_size(path: Path) -> str:
size = path.stat().st_size
for unit in ("B", "KB", "MB", "GB"):
if size < 1024:
return f"{size:.2f} {unit}"
size /= 1024.0
return f"{size:.2f} TB"
def main() -> None:
parser = argparse.ArgumentParser(description="chongming 进程插件打包器")
parser.add_argument("name", help="插件名(如 demo)")
parser.add_argument("--output", default="dist", help="产物输出目录(默认 dist)")
args = parser.parse_args()
try:
zip_path = build_plugin_package(args.name, output_dir=args.output)
except (FileNotFoundError, ValueError) as e:
print(f"❌ {e}")
sys.exit(1)
print(f"✅ 打包完成: {zip_path} ({_format_size(zip_path)})")
print(f"📦 产物 = {_rel(WORKERS_DIR / args.name)}/ 项目根的直接压缩")
print(f"🚀 容器内部署: unzip {args.name}.zip -d /app && cd /app && ./start.sh")
def build_plugin():
"""CLI 入口(供 pyproject.toml [project.scripts] 调用)"""
main()
if __name__ == "__main__":
main()