From 0bf60021cbeb32610dce232eaf0e25be5d8d8b29 Mon Sep 17 00:00:00 2001 From: chakcy Date: Wed, 19 Aug 2026 14:37:51 +0800 Subject: [PATCH] =?UTF-8?q?init:=20chongming-cli=20=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E9=9B=86=E5=90=88=EF=BC=88build=20/=20build?= =?UTF-8?q?=5Fplugin=20/=20gen=5Fmodels=20/=20gen=5Fplugin=20/=20code=5Fco?= =?UTF-8?q?ntent=EF=BC=89=EF=BC=8C=E4=BB=8E=20chongming-core=20=E7=9A=84?= =?UTF-8?q?=20src/chongming/scripts=20=E6=8A=BD=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 72 ++++ README.md | 39 +++ pyproject.toml | 25 ++ src/chongming_cli/__init__.py | 1 + src/chongming_cli/_root.py | 22 ++ src/chongming_cli/build.py | 537 ++++++++++++++++++++++++++++++ src/chongming_cli/build_plugin.py | 246 ++++++++++++++ src/chongming_cli/code_content.py | 47 +++ src/chongming_cli/gen_models.py | 353 ++++++++++++++++++++ src/chongming_cli/gen_plugin.py | 320 ++++++++++++++++++ 10 files changed, 1662 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/chongming_cli/__init__.py create mode 100644 src/chongming_cli/_root.py create mode 100644 src/chongming_cli/build.py create mode 100644 src/chongming_cli/build_plugin.py create mode 100644 src/chongming_cli/code_content.py create mode 100644 src/chongming_cli/gen_models.py create mode 100644 src/chongming_cli/gen_plugin.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8f0442 --- /dev/null +++ b/.gitignore @@ -0,0 +1,72 @@ +/target + +# Byte-compiled / optimized / DLL files +__pycache__/ +.pytest_cache/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +.venv/ +env/ +bin/ +build/ +develop-eggs/ +dist/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +include/ +man/ +venv/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt +pip-selfcheck.json + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Rope +.ropeproject + +# Django stuff: +*.log +*.pot + +.DS_Store + +# Sphinx documentation +docs/_build/ + +# PyCharm +.idea/ + +# VSCode +.vscode/ + +# Pyenv +.python-version diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab80bd5 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# chongming-cli + +chongming 网关的命令脚本集合,独立包维护(从 `src/chongming/scripts` 抽离)。 + +## 命令 + +| 命令 | 说明 | +| ------------ | ------------------------------------------- | +| `build` | 应用构建打包(PyInstaller / Nuitka) | +| `build_plugin` | 进程插件打包为可部署 zip | +| `gen_models` | 从 `config/workers//*.toml` 生成 Pydantic 模型 | +| `gen_plugin` | 生成风格统一的 Worker 插件骨架 | + +另有 `chongming_cli.code_content` 模块:将目录下代码文件汇总为 Markdown(`python -m chongming_cli.code_content `)。 + +## 用法 + +在 chongming 网关项目根目录下(已通过 path/git 依赖安装本包): + +```bash +uv run build pyinstaller # 构建打包 +uv run build_plugin demo # 进程插件打包 → dist/demo.zip +uv run gen_plugin myplugin # 生成插件骨架 +uv run gen_models config/workers/myplugin src/plugins/myplugin +``` + +## 项目根目录定位 + +脚本需要在项目根目录下读写 `config/`、`src/plugins/`、`workers/` 等路径。 +本包安装位置不固定(site-packages),因此根目录按以下顺序解析: + +1. 环境变量 `CHONGMING_ROOT`(显式指定项目根) +2. 当前工作目录向上查找第一个含 `pyproject.toml` 的目录(即项目根) + +## 开发 + +```bash +uv pip install -e utils/cli # 本地可编辑安装 +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8543fc7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +build-backend = "hatchling.build" +requires = ["hatchling"] + +[project] +name = "chongming-cli" +version = "0.1.0" +description = "chongming 命令脚本集合:构建打包 / 进程插件打包 / 插件脚手架 / 模型生成" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "chongming-config", + "module-bank", + "sqlite-vfs", + "tomli", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/chongming_cli"] + +[project.scripts] +build = "chongming_cli.build:build" +build_plugin = "chongming_cli.build_plugin:build_plugin" +gen_models = "chongming_cli.gen_models:gen_models" +gen_plugin = "chongming_cli.gen_plugin:gen_plugin" diff --git a/src/chongming_cli/__init__.py b/src/chongming_cli/__init__.py new file mode 100644 index 0000000..b1383ec --- /dev/null +++ b/src/chongming_cli/__init__.py @@ -0,0 +1 @@ +"""chongming-cli — 命令脚本集合(构建打包 / 插件脚手架 / 模型生成)""" diff --git a/src/chongming_cli/_root.py b/src/chongming_cli/_root.py new file mode 100644 index 0000000..720e2aa --- /dev/null +++ b/src/chongming_cli/_root.py @@ -0,0 +1,22 @@ +"""项目根目录定位 — chongming-cli 独立维护后安装位置不再固定(site-packages), +不能再从 __file__ 向上查找项目根。""" +import os +from pathlib import Path + + +def find_project_root() -> Path: + """定位项目根目录(含 pyproject.toml 的目录)。 + + 解析顺序: + 1. 环境变量 CHONGMING_ROOT(显式指定) + 2. 当前工作目录向上查找第一个含 pyproject.toml 的目录 + 3. 兜底: 当前工作目录 + """ + env = os.environ.get("CHONGMING_ROOT") + if env: + return Path(env) + cwd = Path.cwd() + for parent in (cwd, *cwd.parents): + if (parent / "pyproject.toml").exists(): + return parent + return cwd diff --git a/src/chongming_cli/build.py b/src/chongming_cli/build.py new file mode 100644 index 0000000..6c1b23b --- /dev/null +++ b/src/chongming_cli/build.py @@ -0,0 +1,537 @@ +""" +构建脚本 - 将 chongming 应用打包为可执行文件 + +功能阶段: + 1. 模块打包(module-bank): 加密打包 Python 模块 + 2. 资源准备: 创建 build 目录, 复制必要文件 + 3. 混淆与打包: pyarmor 混淆 + PyInstaller/Nuitka 打包 + 4. 数据库初始化: 创建表结构和默认数据 + 5. 前端构建: 编译 Vue 项目并打包为 VFS + +用法: + uv run build [pyinstaller|nuitka] +""" + +import platform +import shutil +import subprocess +import sys +import asyncio +import time +from dataclasses import dataclass, field +from pathlib import Path + +from module_bank import PythonToSQLite +from module_bank.encryption import Encryption +from sqlite_vfs.core import SQLiteVFS +from sqlite_vfs.folder_packer import FolderPacker +from chongming_config import get_config, Config + +# ── 常量 ──────────────────────────────────────────────────────────────────── + +PYINSTALLER = "pyinstaller" +NUITKA = "nuitka" +MODES = (PYINSTALLER, NUITKA) + +is_windows = platform.system() == "Windows" + +# ── 构建结果数据结构 ───────────────────────────────────────────────────────── + + +@dataclass +class BuildStepResult: + """单个构建步骤的执行结果""" + + name: str # 步骤名称 + success: bool # 是否成功 + duration: float # 耗时(秒) + message: str = "" # 附加信息(如包大小) + + +@dataclass +class BuildReport: + """构建总结报告""" + + mode: str + start_time: float + end_time: float = 0.0 + steps: list[BuildStepResult] = field(default_factory=list) + + @property + def total_duration(self) -> float: + return self.end_time - self.start_time if self.end_time else 0.0 + + @property + def all_success(self) -> bool: + return all(s.success for s in self.steps) + + def add_step(self, name: str, success: bool, duration: float, message: str = ""): + self.steps.append(BuildStepResult(name, success, duration, message)) + + def print_summary(self): + """打印结构化的构建总结""" + sep = "=" * 60 + print(f"\n{sep}") + print(" 🏗️ CHONGMING 构建报告") + print(f"{sep}") + print(f" 构建模式 : {self.mode}") + print(f" 总耗时 : {self.total_duration:.2f}s") + print(f" 构建状态 : {'✅ 成功' if self.all_success else '❌ 失败'}") + print(f"{sep}") + print(" 步骤明细:") + for i, step in enumerate(self.steps, 1): + icon = "✅" if step.success else "❌" + msg = f" ({step.message})" if step.message else "" + print( + f" {icon} [{i}/{len(self.steps)}] {step.name:<20s} {step.duration:>7.2f}s{msg}" + ) + print(f"{sep}\n") + + +# ── 工具函数 ──────────────────────────────────────────────────────────────── + + +def _run_cmd( + cmd: list[str], + cwd: str | None = None, + capture: bool = True, + shell: bool | None = None, + echo_on_error: bool = True, +) -> subprocess.CompletedProcess: + """统一执行外部命令""" + if shell is None: + shell = is_windows + result = subprocess.run( + cmd, + cwd=cwd, + capture_output=capture, + text=True, + shell=shell, + encoding="utf-8", + errors="replace", + ) + # 命令失败时自动回显捕获的输出,便于定位失败原因 + if echo_on_error and capture and result.returncode != 0: + _echo_captured_output(cmd, result) + return result + + +def _echo_captured_output( + cmd: list[str], result: subprocess.CompletedProcess +) -> None: + """命令失败时回显被捕获的输出(截取末尾若干行,避免刷屏)""" + max_lines = 40 + print(f"\n ── 命令执行失败 (exit={result.returncode}): {' '.join(cmd)} ──") + for stream_name, stream in (("stdout", result.stdout), ("stderr", result.stderr)): + if not stream or not stream.strip(): + continue + lines = stream.rstrip("\n").splitlines() + tail = lines[-max_lines:] + header = f" [{stream_name}]" + if len(lines) > max_lines: + header += f" (共 {len(lines)} 行,显示末尾 {len(tail)} 行)" + print(header) + for line in tail: + print(f" {line}") + print(" ────────────────────────────────────────\n") + + +def _format_size(path: str | Path) -> str: + """获取文件可读大小""" + size = Path(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 _ensure_dir(path: str | Path): + """确保目录存在""" + Path(path).mkdir(parents=True, exist_ok=True) + + +def _resolve_mode() -> str: + """解析构建模式""" + mode = PYINSTALLER + if len(sys.argv) >= 2: + mode = sys.argv[1].lower() + if mode not in MODES: + print(f" ❌ 不支持的构建模式: {mode},可选: {', '.join(MODES)}") + sys.exit(1) + return mode + + +# ── 阶段 1: 模块打包 (module-bank) ────────────────────────────────────────── + + +def _stage_pack_modules(config: Config) -> str: + """ + 使用 module-bank 加密打包 Python 模块为 .mbank 文件。 + 返回打包过程中的提示信息。 + """ + _ensure_dir("resources") + mbank_list = config.get("production", {}).get("module_system", {}).get("path", []) + if not mbank_list: + return "⚠️ 未配置 module_system.path,跳过模块打包" + + messages = [] + for output_path, source_dir, package_name in mbank_list: + key = Encryption.generate_key() + packer = PythonToSQLite( + output_path, + key, + pyarmor_obfuscate=True, + ) + packer.pack_directory(source_dir, package_name) + packer.verify_package_structure() + packer.packer_key(key) + packer.delete_source_code(None) + size = _format_size(output_path) + messages.append(f"{Path(output_path).name} ({size})") + return " - " + "\n - ".join(messages) + + +def _stage_prepare_build_dir(config: Config, mode: str) -> str: + """准备 build 目录并复制必要文件""" + build_path = Path("build") + if build_path.exists(): + shutil.rmtree(str(build_path)) + _ensure_dir("build") + + # 复制 utils + utils_src = Path("public/utils") + if utils_src.exists(): + shutil.copytree(utils_src, build_path / "utils") + + # 复制 resources + resources_src = Path("resources") + if resources_src.exists(): + shutil.copytree(resources_src, build_path / "resources") + + resources_src = Path("config") + if resources_src.exists(): + shutil.copytree(resources_src, build_path / "config") + + # 复制核心文件 + for f in ("main.py", "server.py", "config.toml"): + shutil.copy(f"public/{f}", f"build/{f}") + + # 注入 JWT 密钥 + jwt_key = Encryption.generate_key() + cfg_path = build_path / "config.toml" + content = cfg_path.read_text(encoding="utf-8") + content = content.replace("your-secret-key-change-in-production", jwt_key) + cfg_path.write_text(content, encoding="utf-8") + + return "资源准备完成" + + +# ── 阶段 3: PyArmor 混淆 ─────────────────────────────────────────────────── + + +def _stage_pyarmor_obfuscate() -> str: + """对关键脚本执行 PyArmor 混淆""" + files = [ + ("main.py", "./"), + ("server.py", "./"), + ("utils/launch.py", "./utils"), + ("utils/config.py", "./utils"), + ] + failed = [] + for filename, output in files: + result = _run_cmd( + ["pyarmor", "gen", f"--output={output}", filename], + cwd="build", + ) + if result.returncode != 0: + failed.append(filename) + if failed: + return f"❌ 混淆失败: {', '.join(failed)}" + return "✅ 所有文件混淆成功" + + +# ── 阶段 4: PyInstaller 打包 ──────────────────────────────────────────────── + + +def _stage_pyinstaller(config: Config) -> tuple[str, str]: + """执行 PyInstaller 打包,返回 (状态消息, 可执行文件路径)""" + base_imports = config.get("pyinstaller_build_import", {}) + build_imports = list(base_imports.get("base", [])) + build_imports.extend( + base_imports.get("windows", []) if is_windows else base_imports.get("linux", []) + ) + + icon = Path("../../public/chongming.ico") + cmd = [ + sys.executable, + "-m", + "PyInstaller", + "--onefile", + "--noconfirm", + "--optimize", + "2", + "--name", + "chongming", + "--distpath", + ".", + "--workpath", + "./build_temp", + "--specpath", + "./specs", + "--clean", + ] + if not is_windows: + cmd.append("--strip") + if icon.exists(): + cmd.extend(["--icon", str(icon)]) + for imp in build_imports: + cmd.extend(["--hidden-import", imp]) + + cmd.append("--collect-all") + cmd.append("amqtt") + + cmd.append("main.py") + + result = _run_cmd(cmd, cwd="build", capture=True) + if result.returncode != 0: + return f"❌ PyInstaller 失败 (exit={result.returncode}),详情见上方输出", "" + + exe_path = Path("build/chongming") + if is_windows: + exe_path = exe_path.with_suffix(".exe") + + suffix = ".exe" if is_windows else "" + exe = Path("build") / f"chongming{suffix}" + if exe.exists(): + size = _format_size(exe) + return f"✅ PyInstaller 打包成功 ({size})", str(exe) + return "✅ PyInstaller 打包完成 (但未找到可执行文件)", "" + + +# ── 阶段 5: Nuitka 打包 ──────────────────────────────────────────────────── + + +def _stage_nuitka(config: Config) -> tuple[str, str]: + """执行 Nuitka 打包,返回 (状态消息, 可执行文件路径)""" + base_imports = config.get("pyinstaller_build_import", {}).get("base", []) + extra = config.get("pyinstaller_build_import", {}).get( + "windows" if is_windows else "linux", [] + ) + + packages = [ + "module_bank", + "sqlite_vfs", + "fastapi", + "uvicorn", + "gunicorn", + "diskcache", + "chongming_cache", + "chongming_cache.backends", + "chongming_cache.backends.disk", + "apscheduler", + "tomli", + "typing_extensions", + ] + + cmd = [ + sys.executable, + "-m", + "nuitka", + "--standalone", + "--onefile", + "--follow-imports", + "--static-libpython=yes", + "--remove-output", + "--assume-yes-for-downloads", + "--lto=yes", + "--pgo-c", + "--pgo-python", + "--disable-plugin=tk-inter", + "--enable-plugin=numpy", + "--output-dir=.", + "--output-filename=chongming", + "main.py", + "--jobs=4", + ] + if shutil.which("upx"): + cmd.append("--enable-plugin=upx") + for pkg in packages: + cmd.append(f"--include-package={pkg}") + for mod in list(base_imports) + extra: + cmd.append(f"--include-module={mod}") + + if is_windows: + icon = Path("../../public/chongming.ico") + if icon.exists(): + cmd.append(f"--windows-icon-from-ico={icon}") + + result = _run_cmd(cmd, cwd="build", capture=False) + if result.returncode != 0: + # 降级重试(移除可能不兼容的参数) + print( + f" ⚠️ Nuitka 首次编译失败 (exit={result.returncode})," + f"尝试移除不兼容参数重试..." + ) + fallback = [ + arg for arg in cmd if arg not in ("--lto=yes", "--enable-plugin=upx") + ] + _run_cmd(fallback, cwd="build", capture=False) + + exe = Path("build/chongming") + if exe.exists(): + # 后处理瘦身 + if not is_windows: + subprocess.run(["strip", str(exe)], check=False) + if shutil.which("upx"): + subprocess.run(["upx", "--best", str(exe)], check=False) + size = _format_size(exe) + return f"✅ Nuitka 编译完成 ({size})", str(exe) + + return "❌ Nuitka 编译失败 (未生成可执行文件)", "" + + +# ── 阶段 6: 清理临时文件 ──────────────────────────────────────────────────── + + +def _stage_cleanup(mode: str) -> str: + """清理构建过程中的临时文件""" + build = Path("build") + dirs_to_remove = [ + "build_temp", + "specs", + "utils", + "pyarmor_runtime_000000", + "__pycache__", + ] + files_to_remove = ["main.py", "server.py"] + + for d in dirs_to_remove: + target = build / d + if target.exists(): + shutil.rmtree(str(target), ignore_errors=True) + + for f in files_to_remove: + target = build / f + if target.exists(): + target.unlink(missing_ok=True) + + return "临时文件已清理" + +# ── 阶段 8: Vue 前端构建 ──────────────────────────────────────────────────── + + +def _stage_build_vue() -> str: + """构建 Vue 前端并打包为 SQLite VFS""" + dist_dir = "./src/chongming-web/dist" + output_db = "./build/static.svfs" + fs_name = "frontend" + + # 1. npm build + result = _run_cmd( + ["npm", "run", "build"], + cwd="./src/chongming-web", + capture=True, + ) + if result.returncode != 0: + err = (result.stderr or "").strip()[:200] + return f"❌ Vue 构建失败: {err}" + + # 2. 检查产物 + if not Path(dist_dir).exists(): + return "❌ Vue 构建成功但未找到 dist 目录" + + # 3. 打包为 VFS + vfs = SQLiteVFS(output_db, compress=False) + packer = FolderPacker(sqlite_vfs=vfs, exclude_patterns=[], fs_name=fs_name) + try: + packer.pack_folder(dist_dir) + finally: + vfs.close() + + size = _format_size(output_db) + return f"✅ Vue 打包完成 ({size})" + + +# ── 主流程 ────────────────────────────────────────────────────────────────── + + +async def main(): + """规范化构建主流程""" + mode = _resolve_mode() + report = BuildReport(mode=mode, start_time=time.time()) + config = get_config() + + async def run_step(name: str, fn, *args, **kwargs) -> str: + """执行一个构建步骤并记录结果(支持同步和异步函数)""" + start = time.time() + try: + result = fn(*args, **kwargs) + if asyncio.iscoroutine(result): + msg = await result + else: + msg = result + elapsed = time.time() - start + report.add_step(name, success=True, duration=elapsed, message=msg or "") + print(f" ✅ {name:<25s} {elapsed:>7.2f}s {msg}") + return msg + except Exception as e: + elapsed = time.time() - start + err_msg = str(e).strip()[:200] + report.add_step(name, success=False, duration=elapsed, message=err_msg) + print(f" ❌ {name:<25s} {elapsed:>7.2f}s {err_msg}") + return "" + + print("\n" + "=" * 60) + print(" 🏗️ CHONGMING 构建开始") + print(f" 模式: {mode} | 平台: {platform.system()}") + print("=" * 60 + "\n") + + # 阶段 1: 模块打包 + await run_step("模块打包(module-bank)", _stage_pack_modules, config) + + # 阶段 2: 准备构建目录 + await run_step("准备构建目录", _stage_prepare_build_dir, config, mode) + + # 阶段 3+4: 混淆 + 打包 + if mode == PYINSTALLER: + await run_step("PyArmor 混淆", _stage_pyarmor_obfuscate) + start = time.time() + try: + msg, exe_path = _stage_pyinstaller(config) + elapsed = time.time() - start + success = "❌" not in msg + report.add_step( + "PyInstaller 打包", success, elapsed, msg.replace("✅ PyInstaller ", "") + ) + print( + f" {'✅' if success else '❌'} PyInstaller 打包{' ' * 12} {elapsed:>7.2f}s {msg}" + ) + except Exception as e: + elapsed = time.time() - start + report.add_step("PyInstaller 打包", False, elapsed, str(e)[:200]) + print(f" ❌ PyInstaller 打包 {elapsed:>7.2f}s {str(e)[:200]}") + else: + await run_step("Nuitka 编译", lambda: _stage_nuitka(config)[0]) + + # 阶段 5: 清理 + await run_step("清理临时文件", _stage_cleanup, mode) + + # 阶段 7: Vue 前端构建 + await run_step("Vue 前端构建", _stage_build_vue) + + # 总结 + report.end_time = time.time() + report.print_summary() + + if not report.all_success: + sys.exit(1) + + +def build(): + """构建入口""" + asyncio.run(main()) + + +if __name__ == "__main__": + build() diff --git a/src/chongming_cli/build_plugin.py b/src/chongming_cli/build_plugin.py new file mode 100644 index 0000000..6143610 --- /dev/null +++ b/src/chongming_cli/build_plugin.py @@ -0,0 +1,246 @@ +""" +进程插件打包器 — 将 external 进程插件打包为可部署 zip + +用法: + uv run build_plugin [--output ] + +示例: + uv run build_plugin demo # 打包 workers/demo → dist/demo.zip + uv run build_plugin demo --output /tmp/pkg + +产物布局(zip 根即容器工作目录,unzip 后直接 ./start.sh 启动): + workers// 插件代码(模块 workers.) + config/workers// 插件注册 + 端点配置 + config/zenoh_config.json5 Zenoh 会话配置 + config.toml 最小根配置(仅 [zenoh] 节,worker 启动必需) + pyproject.toml uv 依赖清单(chongming-worker + 插件扩展依赖) + start.sh 容器入口: uv sync + uv run worker_serve --type + +说明: + - 插件扩展依赖声明在 workers//requirements.txt(可选,每行一个包), + 打包时自动合并进 pyproject.toml dependencies + - 根 config.toml 仅提取 [zenoh] 节重新生成,避免把网关侧配置(如 JWT 密钥)泄露进插件容器 +""" +import argparse +import re +import shutil +import sys +import tomllib +from pathlib import Path + +from ._root import find_project_root + +NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$") + +# chongming-worker 内网 git 源(与网关 pyproject.toml [tool.uv.sources] 保持一致) +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" + +# 项目根目录(CHONGMING_ROOT 环境变量 > CWD 向上查找,见 _root.py) +ROOT = find_project_root() +WORKERS_DIR = ROOT / "workers" +PLUGINS_CONFIG_DIR = ROOT / "config" / "workers" +ZENOH_CONFIG = ROOT / "config" / "zenoh_config.json5" +ROOT_CONFIG = ROOT / "config.toml" + + +# ============================================================ +# 产物生成 +# ============================================================ + +def _rel(path: Path) -> Path | str: + """相对 ROOT 的路径(不在 ROOT 下时原样返回,供错误消息使用)。""" + try: + return path.relative_to(ROOT) + except ValueError: + return path + + +def _read_plugin_conf(name: str) -> dict: + """读取 config/workers//config.toml 完整内容(校验含 [workers] 节)。""" + path = PLUGINS_CONFIG_DIR / name / "config.toml" + with open(path, "rb") as f: + conf = tomllib.load(f) + if not conf.get("workers"): + raise ValueError(f"{path.relative_to(ROOT)} 缺少 [workers] 节") + return conf + + +def _gen_root_config_toml() -> str: + """生成最小根 config.toml(仅 [zenoh] 节,worker 进程只读它)。""" + with open(ROOT_CONFIG, "rb") as f: + root_conf = tomllib.load(f) + json5_path = root_conf.get("zenoh", {}).get("json5_path", "config/zenoh_config.json5") + return f"""# 最小根配置(由 build_plugin 自动生成,仅含 worker 进程所需的 [zenoh] 节) +[zenoh] +json5_path = "{json5_path}" +""" + + +def _gen_pyproject_toml(name: str, version: str, extra_deps: list[str]) -> str: + """生成插件 uv 依赖清单(chongming-worker + 插件扩展依赖)。""" + deps = [ + "chongming-worker", + "orjson", # chongming-worker 运行时依赖(SDK metadata 未声明,容器内需显式安装) + *extra_deps, + ] + deps_lines = "\n".join(f' "{d}",' for d in deps) + return f"""# {name} 进程插件依赖清单(由 build_plugin 自动生成) +[project] +name = "{name}-worker" +version = "{version}" +description = "{name} 进程插件(external 模式,独立容器部署)" +requires-python = ">=3.12" +dependencies = [ +{deps_lines} +] + +[project.scripts] +worker_serve = "chongming_worker.process:worker_serve" + +[[tool.uv.index]] +url = "{PYPI_MIRROR}" + +[tool.uv.sources] +chongming-worker = {{ git = "{CHONGMING_WORKER_GIT}" }} +""" + + +def _gen_start_sh(name: str) -> str: + """生成容器入口脚本:uv 引导依赖 + 启动 worker 进程。""" + return f"""#!/usr/bin/env bash +# {name} 进程插件容器入口(由 build_plugin 自动生成) +# 要求: 镜像预装 uv(https://docs.astral.sh/uv/) +set -e +cd "$(dirname "$0")" + +uv sync --no-dev +exec uv run worker_serve --type {name} "$@" +""" + + +def _read_extra_deps(name: str) -> list[str]: + """读取 workers//requirements.txt(可选),每行一个依赖。""" + path = WORKERS_DIR / name / "requirements.txt" + if not path.exists(): + return [] + deps = [] + for line in path.read_text(encoding="utf-8").splitlines(): + dep = line.strip() + if dep and not dep.startswith("#"): + deps.append(dep) + return deps + + +# ============================================================ +# 打包主逻辑 +# ============================================================ + +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}'(需小写字母/数字/下划线,且以字母开头)") + + workers_src = WORKERS_DIR / name + if not workers_src.is_dir(): + raise FileNotFoundError(f"未找到插件代码目录: {_rel(workers_src)}/") + + conf = _read_plugin_conf(name) # 缺配置在此抛 FileNotFoundError + workers = conf["workers"] + + plugin_type = workers.get("type", name) + load_mode = workers.get("load_mode", "inline") + if load_mode != "external": + print( + f"⚠️ [{name}] load_mode = \"{load_mode}\":网关也会在进程内加载该插件," + "独立进程将与网关内副本重复服务。进程插件请在 [workers] 节设置 " + "load_mode = \"external\"。" + ) + version = str(conf.get("worker", {}).get("version", "0.1.0")) + + output = Path(output_dir) + stage = output / name + if stage.exists(): + shutil.rmtree(stage) + stage.mkdir(parents=True, exist_ok=True) + + # ---- 1. 插件代码 ---- + shutil.copytree( + workers_src, + stage / "workers" / name, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), + ) + + # ---- 2. 插件配置 ---- + shutil.copytree( + PLUGINS_CONFIG_DIR / name, + stage / "config" / "workers" / name, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), + ) + + # ---- 3. Zenoh 会话配置 ---- + if not ZENOH_CONFIG.exists(): + raise FileNotFoundError(f"未找到 Zenoh 配置: {_rel(ZENOH_CONFIG)}") + shutil.copy(ZENOH_CONFIG, stage / "config" / "zenoh_config.json5") + + # ---- 4. 生成最小根配置 + 依赖清单 + 启动脚本 ---- + (stage / "config.toml").write_text(_gen_root_config_toml(), encoding="utf-8") + (stage / "pyproject.toml").write_text( + _gen_pyproject_toml(name, version, _read_extra_deps(name)), encoding="utf-8" + ) + start_sh = stage / "start.sh" + start_sh.write_text(_gen_start_sh(plugin_type), encoding="utf-8") + start_sh.chmod(0o755) + + # ---- 5. 压缩(zip 根即容器工作目录)---- + zip_path = output / f"{name}.zip" + zip_path.unlink(missing_ok=True) + shutil.make_archive(str(output / name), "zip", root_dir=stage) + + # 暂存目录保留便于排查,也可直接作为容器工作目录 + 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("📦 产物布局(zip 根即容器工作目录):") + print(f" workers/{args.name}/ 插件代码") + print(f" config/workers/{args.name}/ 插件配置") + print(" config/zenoh_config.json5 Zenoh 会话配置") + print(" config.toml 最小根配置(仅 [zenoh] 节)") + print(" pyproject.toml uv 依赖清单") + print(" start.sh 容器入口") + print(f"\n🚀 容器内部署: unzip {args.name}.zip -d /app && cd /app && ./start.sh") + + +def build_plugin(): + """CLI 入口(供 pyproject.toml [project.scripts] 调用)""" + main() + + +if __name__ == "__main__": + main() diff --git a/src/chongming_cli/code_content.py b/src/chongming_cli/code_content.py new file mode 100644 index 0000000..a5e69fc --- /dev/null +++ b/src/chongming_cli/code_content.py @@ -0,0 +1,47 @@ +import os +import sys + + +def generate_markdown_from_py_files(directory, output_file): + with open(output_file, "w", encoding="utf-8") as md_file: + for root, dirs, files in os.walk(directory): + # 排除 venv 目录 + dirs[:] = [d for d in dirs if d != ".venv"] + dirs[:] = [d for d in dirs if d != ".vscode"] + dirs[:] = [d for d in dirs if d != "node_modules"] + dirs[:] = [d for d in dirs if d != "build"] + dirs[:] = [d for d in dirs if d != "dist"] + dirs[:] = [d for d in dirs if d != "chongming-web"] + for file in files: + if ( + file.endswith(".py") + or file.endswith(".rs") + or file.endswith(".toml") + or file.endswith(".md") + or file.endswith(".css") + or file.endswith(".vue") + or file.endswith(".ts") + or file.endswith(".json") + or file.endswith(".html") + or file.endswith(".sh") + or file == "Dockerfile" + ): + file_path = os.path.join(root, file) + if file == "output.md" or file == "package-lock.json": + continue + if "generated" in file_path: + continue + md_file.write(f"`{file_path}`\n") + md_file.write("```\n") + with open(file_path, "r", encoding="utf-8") as py_file: + md_file.write(py_file.read()) + md_file.write("\n```\n\n") + + +if __name__ == "__main__": + # 指定目录和输出文件名 + target_directory = sys.argv[1] # 替换为你的目标目录 + output_markdown_file = "output.md" # 输出的 Markdown 文件名 + + generate_markdown_from_py_files(target_directory, output_markdown_file) + print(f"Markdown 文件已生成:{output_markdown_file}") diff --git a/src/chongming_cli/gen_models.py b/src/chongming_cli/gen_models.py new file mode 100644 index 0000000..6c5cabe --- /dev/null +++ b/src/chongming_cli/gen_models.py @@ -0,0 +1,353 @@ +""" +模型生成脚本 — 从 config/workers//*.toml 生成 Pydantic 模型 + +用法: + uv run gen_models config/workers/example src/plugins/example + +工作流: + 1. 编写 config/workers//*.toml 配置文件 + 2. 执行 gen_models 生成 Pydantic 模型 + 3. 在业务代码中 import 生成的模型 + +功能: + 1. 扫描配置目录下的所有 .toml 文件 + 2. 解析每个路由的 params 和 response_model + 3. 生成 Pydantic 请求/响应模型 + 4. 写入 src/plugins//models/__init__.py 和 models/pydantic.py +""" + +import sys +import json +from pathlib import Path +from typing import Any + + +def parse_type_str(type_str: str) -> str: + """将 TOML 中的类型字符串映射为 Python 类型注解""" + mapping = { + "float": "float", + "int": "int", + "str": "str", + "bool": "bool", + "string": "str", + "integer": "int", + "number": "float", + "boolean": "bool", + "any": "Any", + } + + if type_str.startswith("list[") and type_str.endswith("]"): + inner = type_str[5:-1] + inner_py = mapping.get(inner, inner) + return f"list[{inner_py}]" + + if type_str == "dict": + return "dict[str, Any]" + + if type_str.startswith("dict[") and type_str.endswith("]"): + inner = type_str[5:-1] + if "," in inner: + parts = [p.strip() for p in inner.split(",", 1)] + inner_py = mapping.get(parts[-1], parts[-1]) + else: + inner_py = mapping.get(inner, inner) + return f"dict[str, {inner_py}]" + + if type_str.startswith("Optional[") and type_str.endswith("]"): + inner = type_str[9:-1] + inner_py = mapping.get(inner, inner) + return f"Optional[{inner_py}]" + + return mapping.get(type_str, type_str) + + +def parse_params(params: list[str]) -> list[dict[str, str]]: + """解析 params 列表,如 ['a: float', 'b: float']""" + result = [] + for p in params: + if ":" in p: + name, type_str = p.split(":", 1) + result.append({"name": name.strip(), "type": parse_type_str(type_str.strip())}) + else: + result.append({"name": p.strip(), "type": "str"}) + return result + + +def parse_response_model(response_model: dict[str, Any]) -> list[dict[str, Any]]: + """解析 response_model 字典,返回字段列表""" + fields: list[dict[str, Any]] = [] + for field_name, field_spec in response_model.items(): + entry: dict[str, Any] = {"name": field_name} + if isinstance(field_spec, dict): + entry["type"] = parse_type_str(field_spec.get("type", "str")) + entry["required"] = field_spec.get("required", True) + if "default" in field_spec: + entry["default"] = field_spec["default"] + else: + entry["type"] = parse_type_str(str(field_spec)) + entry["required"] = True + fields.append(entry) + return fields + + +def _model_name(base: str, method: str, suffix: str) -> str: + """生成模型名: e.g. add + GET + Request -> AddGetRequest, add + MQTT + Request -> AddMqttRequest""" + method_title = "Mqtt" if method == "MQTT" else method.title() + return f"{base.title().replace('-', '').replace('_', '')}{method_title}{suffix}" + + +def generate_pydantic_code( + plugin_name: str, + routes: list[dict[str, Any]], +) -> str: + """生成 pydantic.py 文件内容""" + # 收集全部字段类型,按需生成 typing 导入(避免未使用导入触发 lint) + type_strings: list[str] = [] + has_optional_field = False + for route in routes: + for p in route.get("parsed_params", []): + type_strings.append(p.get("type", "str")) + for f in route.get("parsed_response", []): + type_strings.append(f.get("type", "str")) + # 非必填字段渲染为 Optional[...] + if not f.get("required", True): + has_optional_field = True + typing_imports = [] + if has_optional_field or any("Optional" in t for t in type_strings): + typing_imports.append("Optional") + if any("Any" in t for t in type_strings): + typing_imports.append("Any") + + lines = [] + lines.append('"""') + lines.append(f"自动生成的 Pydantic 模型 — {plugin_name}") + lines.append(f"来源: config/workers/{plugin_name}/") + lines.append('"""') + lines.append("") + lines.append("from __future__ import annotations") + lines.append("") + if typing_imports: + lines.append(f"from typing import {', '.join(typing_imports)}") + lines.append("from pydantic import BaseModel, Field") + lines.append("") + + seen_models: set[str] = set() + + for route in routes: + route_name = route["name"] + method = route["method"] + summary = route.get("summary", "") + + # --- Request Model --- + params = route.get("parsed_params", []) + if params: + model_name = _model_name(route_name, method, "Request") + if model_name not in seen_models: + seen_models.add(model_name) + lines.append(f"class {model_name}(BaseModel):") + lines.append(f' """{summary} — 请求体"""') + for p in params: + lines.append(f" {p['name']}: {p['type']} = Field(...)") + lines.append("") + + # --- Response Model --- + response_fields = route.get("parsed_response", []) + if response_fields: + model_name = _model_name(route_name, method, "Response") + if model_name not in seen_models: + seen_models.add(model_name) + lines.append(f"class {model_name}(BaseModel):") + lines.append(f' """{summary} — 响应体"""') + for f in response_fields: + field_type = f["type"] + if f.get("required", True): + if "default" in f: + default_repr = ( + json.dumps(f["default"]) + if not isinstance(f["default"], str) + else f"'{f['default']}'" + ) + lines.append(f" {f['name']}: {field_type} = Field(default={default_repr})") + else: + lines.append(f" {f['name']}: {field_type} = Field(...)") + else: + if "default" in f: + default_repr = ( + json.dumps(f["default"]) + if not isinstance(f["default"], str) + else f"'{f['default']}'" + ) + lines.append(f" {f['name']}: Optional[{field_type}] = Field(default={default_repr})") + else: + lines.append(f" {f['name']}: Optional[{field_type}] = Field(default=None)") + lines.append("") + + # --- Union type for all responses --- + response_models = [m for m in seen_models if m.endswith("Response")] + if response_models: + plugin_title = plugin_name.title().replace("-", "").replace("_", "") + lines.append("") + lines.append("# 所有响应模型的联合类型") + lines.append(f"{plugin_title}Response = " + " | ".join(response_models)) + lines.append("") + + return "\n".join(lines) + + +def generate_init_code( + plugin_name: str, + routes: list[dict[str, Any]], +) -> str: + """生成 __init__.py 文件内容""" + lines = [] + lines.append('"""') + lines.append(f"模型导出 — {plugin_name}") + lines.append("自动生成,请勿手动修改") + lines.append('"""') + lines.append("") + + seen_models: set[str] = set() + for route in routes: + rname = route["name"] + method = route["method"] + if route.get("parsed_params"): + seen_models.add(_model_name(rname, method, "Request")) + if route.get("parsed_response"): + seen_models.add(_model_name(rname, method, "Response")) + + response_models = [m for m in seen_models if m.endswith("Response")] + if response_models: + plugin_title = plugin_name.title().replace("-", "").replace("_", "") + seen_models.add(f"{plugin_title}Response") + + for model_name in sorted(seen_models): + lines.append(f"from .pydantic import {model_name}") + + lines.append("") + lines.append("__all__ = [") + for model_name in sorted(seen_models): + lines.append(f' "{model_name}",') + lines.append("]") + lines.append("") + + return "\n".join(lines) + + +def scan_config_dir(config_dir: Path) -> list[dict[str, Any]]: + """扫描配置目录,解析所有 .toml 文件中的路由定义""" + import tomli + + routes = [] + toml_files = sorted(config_dir.glob("*.toml")) + + group_config = {} + config_file = config_dir / "config.toml" + if config_file.exists(): + with open(config_file, "rb") as f: + group_config = tomli.load(f) + + defaults = group_config.get("worker", {}).get("defaults", {}) + router_prefix = ( + group_config.get("worker", {}) + .get("registration", {}) + .get("router_prefix", "") + ) + + for toml_file in toml_files: + if toml_file.name == "config.toml": + continue + + with open(toml_file, "rb") as f: + data = tomli.load(f) + + route_name = toml_file.stem + + for method in ("GET", "POST", "PUT", "DELETE", "PATCH", "MQTT"): + if method not in data: + continue + + entry = data[method] + params_raw = entry.get("params", []) + response_raw = entry.get("response_model", {}) + + route = { + "name": route_name, + "method": method, + "summary": entry.get("summary", ""), + "docstring": entry.get("docstring", ""), + "internal": entry.get("internal", defaults.get("internal", False)), + "params": params_raw, + "response_model": response_raw, + "parsed_params": parse_params(params_raw), + "parsed_response": parse_response_model(response_raw), + "path": entry.get("path", f"{router_prefix}/{route_name}"), + "timeout": entry.get("timeout", defaults.get("timeout", 3.0)), + "auth_required": entry.get("auth_required", defaults.get("auth_required", False)), + } + routes.append(route) + + return routes + + +def main(): + if len(sys.argv) < 3: + print("用法: uv run gen_models ") + print("示例:") + print(" uv run gen_models config/workers/example src/plugins/example") + sys.exit(1) + + config_dir = Path(sys.argv[1]) + plugin_dir = Path(sys.argv[2]) + plugin_name = plugin_dir.name + + if not config_dir.exists(): + print(f"❌ 配置目录不存在: {config_dir}") + sys.exit(1) + if not plugin_dir.exists(): + print(f"❌ 插件目录不存在: {plugin_dir}") + sys.exit(1) + + print(f"🔍 扫描配置目录: {config_dir}") + routes = scan_config_dir(config_dir) + print(f" 发现 {len(routes)} 个路由定义") + + if not routes: + print("⚠️ 未找到路由定义,跳过模型生成") + return + + models_dir = plugin_dir / "models" + models_dir.mkdir(parents=True, exist_ok=True) + + pydantic_code = generate_pydantic_code(plugin_name, routes) + pydantic_file = models_dir / "pydantic.py" + pydantic_file.write_text(pydantic_code, encoding="utf-8") + print(f"✅ 生成: {pydantic_file}") + + init_code = generate_init_code(plugin_name, routes) + init_file = models_dir / "__init__.py" + init_file.write_text(init_code, encoding="utf-8") + print(f"✅ 生成: {init_file}") + + print("\n📋 生成的模型:") + for route in routes: + method = route["method"] + name = route["name"] + params = route.get("parsed_params", []) + response = route.get("parsed_response", []) + if params: + req_model = _model_name(name, method, "Request") + param_strs = [f"{p['name']}: {p['type']}" for p in params] + print(f" 📥 {req_model}: {', '.join(param_strs)}") + if response: + resp_model = _model_name(name, method, "Response") + field_strs = [f"{f['name']}: {f['type']}" for f in response] + print(f" 📤 {resp_model}: {', '.join(field_strs)}") + + +def gen_models(): + """CLI 入口""" + main() + + +if __name__ == "__main__": + main() diff --git a/src/chongming_cli/gen_plugin.py b/src/chongming_cli/gen_plugin.py new file mode 100644 index 0000000..982cc12 --- /dev/null +++ b/src/chongming_cli/gen_plugin.py @@ -0,0 +1,320 @@ +""" +插件脚手架生成器 — 生成风格统一的 Worker 插件模板 + +用法: + uv run gen-plugin [operation ...] + +示例: + # 生成插件骨架(含一个示例操作): + uv run gen-plugin myplugin + + # 指定初始操作: + uv run gen-plugin myplugin analyze count + + # 为已存在的插件增量添加操作: + uv run gen-plugin myplugin add + +工作流: + 1. uv run gen-plugin myplugin # 生成 config + src 骨架(统一风格) + 2. uv run gen_models config/workers/myplugin src/plugins/myplugin + # 生成 Pydantic 模型 + 3. 编辑 src/plugins/myplugin/router/.py 实现业务逻辑 + 4. 编辑 src/plugins/myplugin/__init__.py 的 on_init / on_cleanup + 5. uv run serve # 启动验证 + +生成内容: + config/workers//config.toml Worker 注册 + 运行时配置 + config/workers//.toml 每个操作的 RPC 端点定义 + src/plugins//__init__.py 基于 Plugin 基类的统一入口 + src/plugins//global_var.py 白名单版全局变量容器 + src/plugins//router/__init__.py 端点模块聚合导出 + src/plugins//router/.py 每个操作的 RPC 实现骨架 + src/plugins//models/ 由 gen_models 生成(见步骤 2) +""" +import re +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" + +NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$") + + +# ============================================================ +# 模板 +# ============================================================ + +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) -> str: + 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) -> str: + return f'''# {name}.{operation} — {operation} 操作 +# 修改后需重新运行: uv run gen_models config/workers/{name} src/plugins/{name} +[GET] +cacheable = true +summary = "{operation} 操作描述" +params = ["value: str"] + +[GET.response_model] +result = {{ type = "str", required = true }} +''' + + +def _gen_plugin_init(name: str) -> str: + cls = snake_to_camel(name) + return f'''"""{name.capitalize()} Worker 插件 + +统一风格模板 — 基于 chongming_worker.plugin.Plugin 基类。 + +工作流: + 1. 编辑 config/workers/{name}/*.toml 定义 RPC 端点 + 2. uv run gen_models config/workers/{name} src/plugins/{name} + 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) -> str: + op_camel = snake_to_camel(operation) + return f'''"""RPC 端点: {name}.{operation} + +模型由 gen_models 生成(编辑 config/workers/{name}/{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 _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(plugin_dir: Path) -> list[str]: + """扫描 router/ 下已有的操作文件(排除 __init__.py)。""" + router_dir = plugin_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 generate_plugin(name: str, operations: list[str]) -> int: + """生成插件骨架。返回 0 表示成功,1 表示失败。""" + if not NAME_RE.match(name): + print(f"❌ 插件名不合法: '{name}'(需小写字母/数字/下划线,且以字母开头)") + return 1 + + config_dir = CONFIG_DIR / name + plugin_dir = PLUGIN_DIR / name + is_new = not plugin_dir.exists() + + # ---- 首次创建: 完整骨架 ---- + if is_new: + _write(config_dir / "config.toml", _gen_config_toml(name)) + for op in operations: + _write(config_dir / f"{op}.toml", _gen_operation_toml(name, op)) + _write(plugin_dir / "__init__.py", _gen_plugin_init(name)) + _write(plugin_dir / "global_var.py", _gen_global_var(name)) + (plugin_dir / "models" / "__init__.py").parent.mkdir(parents=True, exist_ok=True) + (plugin_dir / "models" / "__init__.py").write_text( + '"""模型导出 — 由 gen_models 生成,请勿手动修改"""\n', + encoding="utf-8", + ) + print(f"✅ 生成: {plugin_dir.relative_to(ROOT)}/models/__init__.py") + + # ---- 增量: 仅补 operations(已存在文件不覆盖)---- + existing = _existing_operations(plugin_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)) + _write(plugin_dir / "router" / f"{op}.py", _gen_router_operation(name, op)) + + # ---- 重写 router/__init__.py(聚合所有操作)---- + all_ops = _existing_operations(plugin_dir) + if all_ops: + init_file = plugin_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)}") + + print(f"\n📋 插件 '{name}' 已就绪,下一步:") + print(f" 1. uv run gen_models config/workers/{name} src/plugins/{name}") + print(f" 2. 编辑 src/plugins/{name}/router/ 实现业务逻辑") + print(" 3. uv run serve") + return 0 + + +def main() -> None: + args = sys.argv[1:] + if not args: + print("用法: uv run gen_plugin [operation ...]") + print("示例:") + print(" uv run gen_plugin myplugin") + print(" uv run gen_plugin myplugin analyze count") + print(" uv run gen_plugin myplugin add # 增量添加操作") + sys.exit(1) + + name = args[0] + operations = args[1:] or ["ping"] + sys.exit(generate_plugin(name, operations)) + + +def gen_plugin(): + """CLI 入口(供 pyproject.toml [project.scripts] 调用)""" + main() + + +if __name__ == "__main__": + main()