# -*- coding: utf-8 -*- """ ETL GUI 打包脚本 使用 PyInstaller 将 GUI 应用打包为 Windows EXE 用法: python build_exe.py [--onefile] [--console] [--clean] 参数: --onefile 打包为单个 EXE 文件(默认为目录模式) --console 显示控制台窗口(调试用) --clean 打包前清理旧的构建文件 """ import os import sys import shutil import subprocess from pathlib import Path def get_project_root() -> Path: """获取项目根目录""" return Path(__file__).resolve().parent def clean_build(): """清理旧的构建文件""" project_root = get_project_root() dirs_to_clean = [ project_root / "build", project_root / "dist", ] files_to_clean = [ project_root / "etl_gui.spec", ] for d in dirs_to_clean: if d.exists(): print(f"清理目录: {d}") shutil.rmtree(d) for f in files_to_clean: if f.exists(): print(f"清理文件: {f}") f.unlink() def build_exe(onefile: bool = False, console: bool = False): """构建 EXE""" project_root = get_project_root() # 主入口 main_script = project_root / "gui" / "main.py" # 资源文件 resources_dir = project_root / "gui" / "resources" database_dir = project_root / "database" # 构建 PyInstaller 命令 # 使用 ASCII 名称避免 Windows 控制台编码问题 cmd = [ sys.executable, "-m", "PyInstaller", "--name", "ETL_Manager", "--noconfirm", ] # 单文件或目录模式 if onefile: cmd.append("--onefile") else: cmd.append("--onedir") # 窗口模式 if not console: cmd.append("--windowed") # 添加数据文件 # 样式表 if resources_dir.exists(): cmd.extend(["--add-data", f"{resources_dir};gui/resources"]) # 数据库 SQL 文件 if database_dir.exists(): for sql_file in database_dir.glob("*.sql"): cmd.extend(["--add-data", f"{sql_file};database"]) # 隐式导入 hidden_imports = [ # PySide6 核心模块 "PySide6.QtCore", "PySide6.QtGui", "PySide6.QtWidgets", # 数据库 "psycopg2", "psycopg2.extras", "psycopg2.extensions", # GUI 模块 "gui.models.task_model", "gui.models.schedule_model", "gui.utils.cli_builder", "gui.utils.config_helper", "gui.utils.app_settings", "gui.workers.task_worker", "gui.workers.db_worker", "gui.widgets.settings_dialog", ] for imp in hidden_imports: cmd.extend(["--hidden-import", imp]) # 排除不需要的模块(减小体积) excludes = [ "matplotlib", "numpy", "pandas", "scipy", "PIL", "cv2", "tkinter", ] for exc in excludes: cmd.extend(["--exclude-module", exc]) # 工作目录 cmd.extend(["--workpath", str(project_root / "build")]) cmd.extend(["--distpath", str(project_root / "dist")]) cmd.extend(["--specpath", str(project_root)]) # 主脚本 cmd.append(str(main_script)) print("执行命令:") print(" ".join(cmd)) print() # 执行打包 result = subprocess.run(cmd, cwd=str(project_root)) if result.returncode == 0: # 打包后精简:删除不需要的文件 slim_dist(project_root / "dist" / "ETL_Manager" / "_internal") print() print("=" * 50) print("打包成功!") print(f"输出目录: {project_root / 'dist'}") print("=" * 50) else: print() print("打包失败,请检查错误信息") sys.exit(1) def slim_dist(internal_dir: Path): """精简打包后的文件,删除不需要的内容""" if not internal_dir.exists(): return print() print("精简打包文件...") removed_size = 0 # 1. 删除不需要的翻译文件(只保留中文和英文) translations_dir = internal_dir / "PySide6" / "translations" if translations_dir.exists(): keep_langs = {"zh_CN", "zh_TW", "en"} for qm_file in translations_dir.glob("*.qm"): # 检查是否是需要保留的语言 keep = False for lang in keep_langs: if lang in qm_file.name: keep = True break if not keep: size = qm_file.stat().st_size qm_file.unlink() removed_size += size # 2. 删除 opengl32sw.dll(软件渲染,20MB,通常不需要) opengl_sw = internal_dir / "PySide6" / "opengl32sw.dll" if opengl_sw.exists(): size = opengl_sw.stat().st_size opengl_sw.unlink() removed_size += size print(f" 删除: opengl32sw.dll ({size / 1024 / 1024:.1f} MB)") # 3. 删除不需要的 Qt 模块 DLL(如果存在) unnecessary_dlls = [ "Qt6Pdf.dll", # PDF 支持 "Qt6Qml.dll", # QML 引擎 "Qt6QmlMeta.dll", "Qt6QmlModels.dll", "Qt6QmlWorkerScript.dll", "Qt6Quick.dll", # Quick UI "Qt6VirtualKeyboard.dll", # 虚拟键盘 ] pyside6_dir = internal_dir / "PySide6" for dll_name in unnecessary_dlls: dll_path = pyside6_dir / dll_name if dll_path.exists(): size = dll_path.stat().st_size dll_path.unlink() removed_size += size print(f" 删除: {dll_name} ({size / 1024 / 1024:.1f} MB)") # 4. 删除不需要的插件目录 unnecessary_plugins = [ "networkinformation", # 网络信息 "tls", # TLS 支持(数据库已有) ] plugins_dir = pyside6_dir / "plugins" if plugins_dir.exists(): for plugin_name in unnecessary_plugins: plugin_path = plugins_dir / plugin_name if plugin_path.exists(): size = sum(f.stat().st_size for f in plugin_path.rglob("*") if f.is_file()) shutil.rmtree(plugin_path) removed_size += size print(f" 删除插件: {plugin_name}") print(f"共节省: {removed_size / 1024 / 1024:.1f} MB") def main(): """主函数""" import argparse parser = argparse.ArgumentParser(description="ETL GUI 打包工具") parser.add_argument("--onefile", action="store_true", help="打包为单个 EXE") parser.add_argument("--console", action="store_true", help="显示控制台窗口") parser.add_argument("--clean", action="store_true", help="打包前清理") args = parser.parse_args() # 检查 PyInstaller try: import PyInstaller print(f"PyInstaller 版本: {PyInstaller.__version__}") except ImportError: print("错误: 未安装 PyInstaller") print("请运行: pip install pyinstaller") sys.exit(1) # 清理 if args.clean: clean_build() # 构建 build_exe(onefile=args.onefile, console=args.console) if __name__ == "__main__": main()