504 lines
18 KiB
Python
504 lines
18 KiB
Python
"""
|
||
插件脚手架生成器 — 生成风格统一的 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()
|