init: chongming-cli 命令脚本集合(build / build_plugin / gen_models / gen_plugin / code_content),从 chongming-core 的 src/chongming/scripts 抽离
This commit is contained in:
+72
@@ -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
|
||||||
@@ -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 # 本地可编辑安装
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""chongming-cli — 命令脚本集合(构建打包 / 插件脚手架 / 模型生成)"""
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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}")
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user