init: chongming-cli 命令脚本集合(build / build_plugin / gen_models / gen_plugin / code_content),从 chongming-core 的 src/chongming/scripts 抽离

This commit is contained in:
chakcy
2026-08-19 14:37:51 +08:00
commit 0bf60021cb
10 changed files with 1662 additions and 0 deletions
+72
View File
@@ -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
+39
View File
@@ -0,0 +1,39 @@
# chongming-cli
chongming 网关的命令脚本集合,独立包维护(从 `src/chongming/scripts` 抽离)。
## 命令
| 命令 | 说明 |
| ------------ | ------------------------------------------- |
| `build` | 应用构建打包(PyInstaller / Nuitka) |
| `build_plugin` | 进程插件打包为可部署 zip |
| `gen_models` | 从 `config/workers/<name>/*.toml` 生成 Pydantic 模型 |
| `gen_plugin` | 生成风格统一的 Worker 插件骨架 |
另有 `chongming_cli.code_content` 模块:将目录下代码文件汇总为 Markdown(`python -m chongming_cli.code_content <dir>`)。
## 用法
在 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 # 本地可编辑安装
```
+25
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
"""chongming-cli — 命令脚本集合(构建打包 / 插件脚手架 / 模型生成)"""
+22
View File
@@ -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
+537
View File
@@ -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()
+246
View File
@@ -0,0 +1,246 @@
"""
进程插件打包器 — 将 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
产物布局(zip 根即容器工作目录,unzip 后直接 ./start.sh 启动):
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>
说明:
- 插件扩展依赖声明在 workers/<name>/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/<name>/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/<name>/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()
+47
View File
@@ -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}")
+353
View File
@@ -0,0 +1,353 @@
"""
模型生成脚本 — 从 config/workers/<plugin_name>/*.toml 生成 Pydantic 模型
用法:
uv run gen_models config/workers/example src/plugins/example
工作流:
1. 编写 config/workers/<plugin_name>/*.toml 配置文件
2. 执行 gen_models 生成 Pydantic 模型
3. 在业务代码中 import 生成的模型
功能:
1. 扫描配置目录下的所有 .toml 文件
2. 解析每个路由的 params 和 response_model
3. 生成 Pydantic 请求/响应模型
4. 写入 src/plugins/<plugin_name>/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 <config_dir> <plugin_dir>")
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()
+320
View File
@@ -0,0 +1,320 @@
"""
插件脚手架生成器 — 生成风格统一的 Worker 插件模板
用法:
uv run gen-plugin <name> [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/<operation>.py 实现业务逻辑
4. 编辑 src/plugins/myplugin/__init__.py 的 on_init / on_cleanup
5. uv run serve # 启动验证
生成内容:
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 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 <name> [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()