504 lines
16 KiB
Python
504 lines
16 KiB
Python
"""
|
|||
|
|
构建脚本 - 将 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()
|