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

504 lines
18 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.
"""
插件脚手架生成器 — 生成风格统一的 Worker 插件模板
用法:
uv run gen_plugin <name> [operation ...] [--process]
示例:
# 生成 inline 插件骨架(默认,代码在 src/plugins/,配置在 config/workers/):
uv run gen_plugin myplugin
# 生成进程插件完整项目(自包含,代码 + 配置 + 依赖清单 + 入口脚本):
uv run gen_plugin myplugin --process
# 指定初始操作:
uv run gen_plugin myplugin analyze count --process
# 为已存在的插件增量添加操作:
uv run gen_plugin myplugin add --process
生成内容(--process): workers/<name>/ 自包含项目根(= 打包 zip 的镜像):
workers/<name>/workers/<name>/__init__.py 插件入口(Plugin 基类 + 模块级工厂)
workers/<name>/workers/<name>/router/ 端点模块聚合 + 每个操作的 RPC 实现骨架
workers/<name>/config/workers/<name>/config.toml 插件注册(module=workers.<name>, load_mode=external)
workers/<name>/config/workers/<name>/<op>.toml 每个操作的 RPC 端点定义
workers/<name>/config/zenoh_config.json5 Zenoh 会话配置(自仓库根复制)
workers/<name>/config.toml 最小根配置(仅 [zenoh] 节)
workers/<name>/pyproject.toml uv 依赖清单(chongming-worker + orjson)
workers/<name>/start.sh 入口脚本(uv sync + chongming-worker)
生成内容(默认 inline):
config/workers/<name>/config.toml Worker 注册 + 运行时配置
config/workers/<name>/<op>.toml 每个操作的 RPC 端点定义
src/plugins/<name>/__init__.py 基于 Plugin 基类的统一入口
src/plugins/<name>/global_var.py 白名单版全局变量容器
src/plugins/<name>/router/__init__.py 端点模块聚合导出
src/plugins/<name>/router/<op>.py 每个操作的 RPC 实现骨架
src/plugins/<name>/models/ 由 gen_models 生成(见步骤 2)
"""
import argparse
import re
import shutil
import sys
from pathlib import Path
from ._root import find_project_root
# 项目根目录(CHONGMING_ROOT 环境变量 > CWD 向上查找,见 _root.py)
ROOT = find_project_root()
CONFIG_DIR = ROOT / "config" / "workers"
PLUGIN_DIR = ROOT / "src" / "plugins"
PROCESS_DIR = ROOT / "workers"
ZENOH_CONFIG = ROOT / "config" / "zenoh_config.json5"
NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
# chongming-worker 内网 git 源(与网关 pyproject.toml [tool.uv.sources] 保持一致)
GITEA_PYPI_URL = "http://124.71.68.6:3000/api/packages/chongming/pypi/simple/"
CHONGMING_WORKER_GIT = "http://124.71.68.6:3000/chongming/chongming-utils-worker.git"
PYPI_MIRROR = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
# ============================================================
# 模板
# ============================================================
def snake_to_camel(name: str) -> str:
"""snake_case -> CamelCase(如 analyze_media -> AnalyzeMedia)"""
return "".join(part.title() for part in name.split("_"))
def _gen_config_toml(name: str, process: bool = False) -> str:
if process:
return f'''# ====== 插件注册信息(进程插件:独立进程运行) ======
[workers]
type = "{name}"
module = "workers.{name}"
factory = "create_workers"
cleanup = "cleanup"
count = 1
# 进程插件:由 Docker/K8s 编排拉起独立进程(容器入口 ./start.sh / chongming-worker --type {name}),
# 经 Zenoh 注册路由,网关动态挂载;与 src/plugins/ 下的 inline 插件可同时存在
load_mode = "external"
[worker]
name = "{name}"
version = "0.1.0"
description = "{name} 进程插件 — 独立进程运行,经 Zenoh 注册路由"
config_version = "1.0"
defaults = {{ method = "GET", timeout = 30.0, auth_required = false, cacheable = false, internal = false }}
[worker.registration]
router_prefix = "/{name}"
tags = ["{name}", "process-plugin"]
# ====== Worker 运行时配置(由 worker_serve 传递给 create_workers) ======
[workers.config]
data_dir = "./data"
'''
return f'''# {name} Worker 插件配置
[worker]
[worker.defaults]
timeout = 30
cacheable = false
[worker.registration]
router_prefix = "/{name}"
# Worker 注册(app lifespan 据此发现并加载插件)
[workers]
type = "{name}"
module = "plugins.{name}"
factory = "create_workers"
cleanup = "cleanup"
count = 1
# 运行时配置(展平后传给 create_workers 的 config 参数)
[workers.config]
data_dir = "./data"
'''
def _gen_operation_toml(name: str, operation: str, gen_models_cmd: str) -> str:
return f'''# {name}.{operation} — {operation} 操作
# 修改后需重新运行: uv run gen_models {gen_models_cmd}
[GET]
cacheable = true
summary = "{operation} 操作描述"
params = ["value: str"]
[GET.response_model]
result = {{ type = "str", required = true }}
'''
def _gen_plugin_init(name: str, config_rel: str, code_rel: str) -> str:
cls = snake_to_camel(name)
return f'''"""{name.capitalize()} Worker 插件
统一风格模板 — 基于 chongming_worker.plugin.Plugin 基类。
工作流:
1. 编辑 {config_rel}/*.toml 定义 RPC 端点
2. uv run gen_models {config_rel} {code_rel}
3. 在 router/<操作>.py 中实现业务逻辑
4. 在 on_init / on_cleanup 中初始化 / 清理资源
"""
import logging
from chongming_worker.plugin import Plugin
from . import router # noqa: F401 触发 @rpc_method 注册
# from .routes import router # 可选: 自定义 FastAPI 路由(网关自动发现)
logger = logging.getLogger(__name__)
class {cls}Plugin(Plugin):
"""{name} 插件 — 统一生命周期。"""
type = "{name}"
db_name = "{name}.db" # 自动在 data_dir 下建 SQLite 库(不需要则置 None)
auto_bind = True # 首次初始化时自动 auto_bind_models(self.type)
async def on_init(self):
"""首次 create_workers 时调用一次:初始化资源、启动后台任务。
数据库已由基类 _bootstrap 自动初始化(self.db)。
"""
self.set_var("db", self.db)
# 需要后台任务时:
# self.spawn_task(self._background_loop())
logger.info("[{name}] 初始化完成")
async def on_cleanup(self):
"""cleanup 时调用:关闭自定义客户端等(后台任务与数据库由基类处理)。"""
logger.info("[{name}] 清理完成")
# 暴露给 app lifespan 的模块级入口(bound method,签名与旧写法完全一致)
_plugin = {cls}Plugin()
create_workers = _plugin.create_workers
cleanup = _plugin.cleanup
'''
def _gen_global_var(name: str) -> str:
return f'''"""{name.capitalize()} 插件全局运行时状态 — 统一容器
约定:
- 模块只做"声明默认值",不产生任何导入期副作用
- 所有值统一在 Plugin.on_init 中通过 set_global_var 显式写入
- set_global_var 仅允许写入 _ALLOWED 白名单,拼写错误会立即抛错
- require() 用于取"必须已初始化"的值,未初始化时抛清晰错误
"""
from typing import Any
from chongming_database import Database
# 允许写入的全局变量白名单(防止拼写错误静默创建无用的新变量)
_ALLOWED = frozenset({{"db"}})
# 显式声明所有运行时状态(默认 None,运行时由 on_init 写入)
db: Database | None = None
def set_global_var(name: str, value: Any) -> None:
"""白名单校验版 setter — 设置模块级运行时状态。"""
if name not in _ALLOWED:
raise KeyError(
f"global_var: 未知变量 '{{name}}'(允许: {{sorted(_ALLOWED)}})"
)
globals()[name] = value
def require(name: str) -> Any:
"""获取必须已初始化的全局变量,未初始化时抛清晰错误。"""
if name not in _ALLOWED:
raise KeyError(f"global_var: 未知变量 '{{name}}'(允许: {{sorted(_ALLOWED)}})")
value = globals().get(name)
if value is None:
raise RuntimeError(
f"global_var: '{{name}}' 未初始化 —— 请确认插件 create_workers 已执行且配置正确"
)
return value
__all__ = sorted(_ALLOWED) + ["set_global_var", "require"]
'''
def _gen_router_init(name: str, operations: list[str]) -> str:
lines = [f'"""{name.capitalize()} RPC 端点"""', ""]
for op in operations:
lines.append(f"from . import {op}")
lines.append("")
lines.append("__all__ = " + repr(operations))
lines.append("")
return "\n".join(lines)
def _gen_router_operation(name: str, operation: str, config_rel: str) -> str:
op_camel = snake_to_camel(operation)
return f'''"""RPC 端点: {name}.{operation}
模型由 gen_models 生成(编辑 {config_rel}/{operation}.toml 后重新生成)。
"""
from chongming_worker.decorators import rpc_method
from ..models.pydantic import {op_camel}GetRequest, {op_camel}GetResponse
@rpc_method(
subject="{name}.{operation}",
method="GET",
summary="{operation} 操作",
response_model={op_camel}GetResponse,
)
async def {operation}(request: {op_camel}GetRequest) -> {op_camel}GetResponse:
"""业务逻辑实现。"""
return {op_camel}GetResponse(result=f"hello: {{request.value}}")
'''
def _gen_root_config_toml() -> str:
"""最小根配置(仅 [zenoh] 节,worker 进程只读它)。"""
return f"""# 最小根配置(worker 进程启动只读 [zenoh] 节)
[zenoh]
json5_path = "config/zenoh_config.json5"
"""
def _gen_pyproject_toml(name: str) -> str:
"""插件 uv 依赖清单(chongming-worker + 扩展依赖,扩展依赖自行追加)。
注意:不声明 [project.scripts]——插件项目非打包项目(无 build-system),
uv sync 会跳过其入口点;worker 进程入口由 SDK 的 chongming-worker 脚本提供。
"""
deps = [
"chongming-cache",
"chongming-worker",
]
deps_lines = "\n".join(f' "{d}",' for d in deps)
return f"""# {name} 进程插件依赖清单(扩展依赖直接在此追加,如 "minio")
[project]
name = "{name}-worker"
version = "0.1.0"
description = "{name} 进程插件(external 模式,独立容器部署)"
requires-python = ">=3.12"
dependencies = [
{deps_lines}
]
[[tool.uv.index]]
url = "{GITEA_PYPI_URL}"
[[tool.uv.index]]
url = "{PYPI_MIRROR}"
[dependency-groups]
dev = [
"chongming-cli",
]
"""
def _gen_start_sh(name: str) -> str:
"""容器入口脚本:uv 引导依赖 + 启动 worker 进程(SDK 的 chongming-worker 脚本)。"""
return f"""#!/usr/bin/env bash
# {name} 进程插件容器入口(由 gen_plugin --process 生成)
# 要求: 镜像预装 uv(https://docs.astral.sh/uv/)
set -e
cd "$(dirname "$0")"
uv sync --no-dev
exec uv run chongming-worker --type {name} "$@"
"""
def _gen_readme(name: str) -> str:
return f"""# {name} 进程插件
独立进程运行的 Worker 插件(`load_mode = "external"`),与网关零导入关系、仅经 Zenoh 通讯。
## 目录结构(开发目录即打包产物)
```text
.
├── workers/{name}/ 插件代码(模块 workers.{name})
├── config/
│ ├── zenoh_config.json5 Zenoh 会话配置
│ └── workers/{name}/ 插件注册 + 端点配置(config.toml + <op>.toml)
├── config.toml 最小根配置(仅 [zenoh] 节)
├── pyproject.toml uv 依赖清单(扩展依赖直接在此追加)
└── start.sh 入口脚本(uv sync + uv run chongming-worker --type {name})
```
## 开发
```bash
# 生成 Pydantic 模型(在仓库根执行,目录参数相对仓库根)
uv run gen_models workers/{name}/config/workers/{name} workers/{name}/workers/{name}
# 本地调试(在项目根执行,或直接 ./start.sh)
cd workers/{name}
uv sync # 首次:安装依赖(项目独立 venv,与容器一致)
uv run chongming-worker --type {name}
```
## 打包(在仓库根执行)
```bash
uv run build_plugin {name} # → dist/{name}.zip(= 本目录的直接压缩)
```
详见 docs/process-plugin.md。
"""
# ============================================================
# 主逻辑
# ============================================================
def _write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists():
print(f"⏭️ 跳过(已存在): {path.relative_to(ROOT)}")
return
path.write_text(content, encoding="utf-8")
print(f"✅ 生成: {path.relative_to(ROOT)}")
def _existing_operations(code_dir: Path) -> list[str]:
"""扫描 router/ 下已有的操作文件(排除 __init__.py)。"""
router_dir = code_dir / "router"
if not router_dir.exists():
return []
ops = []
for f in sorted(router_dir.glob("*.py")):
if f.name == "__init__.py":
continue
ops.append(f.stem)
return ops
def _gen_models_cmd(config_dir: Path, code_dir: Path) -> str:
"""gen_models 的两个目录参数(相对 ROOT,命令在 ROOT 下执行)。"""
return f"{config_dir.relative_to(ROOT)} {code_dir.relative_to(ROOT)}"
def generate_plugin(name: str, operations: list[str], process: bool = False) -> int:
"""生成插件骨架。返回 0 表示成功,1 表示失败。"""
if not NAME_RE.match(name):
print(f"❌ 插件名不合法: '{name}'(需小写字母/数字/下划线,且以字母开头)")
return 1
if process:
project_dir = PROCESS_DIR / name
config_dir = project_dir / "config" / "workers" / name
code_dir = project_dir / "workers" / name
else:
config_dir = CONFIG_DIR / name
code_dir = PLUGIN_DIR / name
gen_models_cmd = _gen_models_cmd(config_dir, code_dir)
is_new = not code_dir.exists()
# ---- 首次创建: 完整骨架 ----
if is_new:
_write(config_dir / "config.toml", _gen_config_toml(name, process))
for op in operations:
_write(config_dir / f"{op}.toml", _gen_operation_toml(name, op, gen_models_cmd))
_write(code_dir / "__init__.py", _gen_plugin_init(
name, config_dir.relative_to(ROOT), code_dir.relative_to(ROOT)
))
_write(code_dir / "global_var.py", _gen_global_var(name))
(code_dir / "models" / "__init__.py").parent.mkdir(parents=True, exist_ok=True)
(code_dir / "models" / "__init__.py").write_text(
'"""模型导出 — 由 gen_models 生成,请勿手动修改"""\n',
encoding="utf-8",
)
print(f"✅ 生成: {code_dir.relative_to(ROOT)}/models/__init__.py")
if process:
_write(project_dir / "config.toml", _gen_root_config_toml())
_write(project_dir / "pyproject.toml", _gen_pyproject_toml(name))
_write(project_dir / "README.md", _gen_readme(name))
_write(project_dir / "start.sh", _gen_start_sh(name))
(project_dir / "start.sh").chmod(0o755)
# Zenoh 会话配置自仓库根复制(后续可自行修改为插件专用配置)
if ZENOH_CONFIG.exists():
dest = project_dir / "config" / "zenoh_config.json5"
if dest.exists():
print(f"⏭️ 跳过(已存在): {dest.relative_to(ROOT)}")
else:
shutil.copy(ZENOH_CONFIG, dest)
print(f"✅ 生成: {dest.relative_to(ROOT)}(自 {_rel(ZENOH_CONFIG)} 复制)")
else:
print(
f"⚠️ 未找到 Zenoh 配置: {_rel(ZENOH_CONFIG)},"
"请自行放入 config/zenoh_config.json5"
)
# ---- 增量: 仅补 operations(已存在文件不覆盖)----
existing = _existing_operations(code_dir)
added = [op for op in operations if op not in existing]
if added:
for op in added:
_write(config_dir / f"{op}.toml", _gen_operation_toml(name, op, gen_models_cmd))
_write(code_dir / "router" / f"{op}.py", _gen_router_operation(name, op, str(config_dir.relative_to(ROOT))))
# ---- 重写 router/__init__.py(聚合所有操作)----
all_ops = _existing_operations(code_dir)
if all_ops:
init_file = code_dir / "router" / "__init__.py"
init_file.parent.mkdir(parents=True, exist_ok=True)
init_file.write_text(_gen_router_init(name, all_ops), encoding="utf-8")
print(f"✅ 更新: {init_file.relative_to(ROOT)}")
mode = "进程插件项目" if process else "inline 插件"
print(f"\n📋 {mode} '{name}' 已就绪,下一步:")
print(f" 1. uv run gen_models {gen_models_cmd}")
print(f" 2. 编辑 {code_dir.relative_to(ROOT)}/router/ 实现业务逻辑")
if process:
print(f" 3. cd {PROCESS_DIR.relative_to(ROOT)}/{name} && ./start.sh 或 uv run chongming-worker --type {name}")
print(f" 4. uv run build_plugin {name}")
else:
print(" 3. uv run serve")
return 0
def _rel(path: Path) -> Path | str:
"""相对 ROOT 的路径(不在 ROOT 下时原样返回,供错误消息使用)。"""
try:
return path.relative_to(ROOT)
except ValueError:
return path
def main() -> None:
parser = argparse.ArgumentParser(description="chongming 插件脚手架生成器")
parser.add_argument("name", help="插件名(如 myplugin)")
parser.add_argument("operations", nargs="*", help="初始操作名(默认 ping;`add` 为增量添加)")
parser.add_argument(
"--process",
action="store_true",
help="生成进程插件完整项目(workers/<name>/,自包含,可打包部署)",
)
args = parser.parse_args()
operations = args.operations or ["ping"]
sys.exit(generate_plugin(args.name, operations, process=args.process))
def gen_plugin():
"""CLI 入口(供 pyproject.toml [project.scripts] 调用)"""
main()
if __name__ == "__main__":
main()