""" 构建脚本 - 将 chongming 应用打包为可执行文件 功能阶段: 1. 模块打包(module-bank): 加密打包 Python 模块 2. 资源准备: 创建 build 目录, 复制必要文件 3. 混淆与打包: pyarmor 混淆 + PyInstaller/Nuitka 打包 4. 数据库初始化: 创建表结构和默认数据 5. 前端构建: 编译 Vue 项目并打包为 VFS 用法: python -m chongming.scripts.build [pyinstaller|nuitka] """ import json import ast 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 ..app.core.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(f" 🏗️ 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(f" 步骤明细:") 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, ) -> subprocess.CompletedProcess: """统一执行外部命令""" if shell is None: shell = is_windows return subprocess.run( cmd, cwd=cwd, capture_output=capture, text=True, shell=shell, encoding="utf-8", errors="replace", ) 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("main.py") result = _run_cmd(cmd, cwd="build", capture=True) if result.returncode != 0: return f"❌ PyInstaller 失败", "" 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", "apscheduler", "tomli", "typing_extensions", ] cmd = [ sys.executable, "-m", "nuitka", "--standalone", "--onefile", "--follow-imports", "--static-libpython=no", "--remove-output", "--assume-yes-for-downloads", "--lto=yes", "--disable-plugin=tk-inter", "--disable-plugin=numpy", "--output-dir=.", "--output-filename=chongming", "main.py", "--jobs=2", ] 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}") try: _run_cmd(cmd, cwd="build", capture=False) except subprocess.CalledProcessError: # 降级重试(移除可能不兼容的参数) 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()