-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbuild.exe.py
More file actions
399 lines (318 loc) · 13.1 KB
/
Copy pathbuild.exe.py
File metadata and controls
399 lines (318 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
"""使用 Nuitka 将 BitWave 打包为 Windows 可执行文件。
用法:
python build.exe.py
python build.exe.py --console
python build.exe.py --debug --clean
``--`` 后面的额外参数会原样传递给 Nuitka:
python build.exe.py -- --show-progress
"""
from __future__ import annotations
import argparse
import importlib
import importlib.util
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent
ENTRY_FILE = PROJECT_ROOT / "main.py"
DIST_DIR = PROJECT_ROOT / "dist"
BUILD_DIR = PROJECT_ROOT / "build"
EXE_NAME = "BitWave"
DEFAULT_APP_NAME = "BitWave"
DEFAULT_APP_VERSION = "0.0.0"
DEFAULT_APP_COMPANY = "Qing90bing"
DEFAULT_APP_COPYRIGHT = "Copyright (c) 2026 Qing90bing"
@dataclass(frozen=True)
class BuildMetadata:
"""打包时写入 exe 版本资源的信息。"""
app_name: str
app_version: str
company: str
copyright: str
def _read_config_value(config: object, name: str, default: str) -> str:
"""从 config.py 读取字符串配置,缺失或为空时使用默认值。"""
value = getattr(config, name, default)
value = str(value).strip()
return value or default
def _normalize_display_version(version: str) -> str:
"""将 V1.0.1 这类展示版本转换为 1.0.1,便于复用到版本资源。"""
return version.strip().lstrip("vV") or DEFAULT_APP_VERSION
def _load_build_metadata() -> BuildMetadata:
"""优先从 config.py 读取版本信息,避免应用内版本和 exe 版本不一致。"""
try:
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
config = importlib.import_module("config")
except Exception as exc:
print(f"读取 config.py 失败,将使用默认打包信息:{exc}", file=sys.stderr)
return BuildMetadata(
app_name=DEFAULT_APP_NAME,
app_version=DEFAULT_APP_VERSION,
company=DEFAULT_APP_COMPANY,
copyright=DEFAULT_APP_COPYRIGHT,
)
display_version = _read_config_value(
config, "ABOUT_APP_VERSION", DEFAULT_APP_VERSION
)
return BuildMetadata(
app_name=_read_config_value(config, "ABOUT_APP_NAME", DEFAULT_APP_NAME),
app_version=_normalize_display_version(display_version),
company=_read_config_value(config, "ABOUT_COMPANY", DEFAULT_APP_COMPANY),
copyright=_read_config_value(config, "ABOUT_COPYRIGHT", DEFAULT_APP_COPYRIGHT),
)
METADATA = _load_build_metadata()
def _windows_version(version: str) -> str:
"""返回 Windows 版本资源接受的四段数字版本号。"""
parts = version.strip().lstrip("vV").split(".")
numeric_parts = []
for part in parts:
number = "".join(ch for ch in part if ch.isdigit())
numeric_parts.append(number or "0")
while len(numeric_parts) < 4:
numeric_parts.append("0")
return ".".join(numeric_parts[:4])
def _configure_console_encoding() -> None:
"""尽量使用 UTF-8 输出,避免中文或版权符号在 GBK 控制台中打断脚本。"""
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if not callable(reconfigure):
continue
try:
reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
def _relative(path: Path) -> str:
"""计算给定路径相对于项目根目录的相对路径字符串,常用于控制台日志输出以简化路径显示。"""
return str(path.relative_to(PROJECT_ROOT))
def _remove_dir(path: Path) -> None:
"""仅删除项目目录内部的生成目录,避免误删其他路径。"""
resolved = path.resolve()
root = PROJECT_ROOT.resolve()
if resolved == root or root not in resolved.parents:
raise RuntimeError(f"拒绝删除项目目录之外的路径:{resolved}")
if path.exists():
print(f"正在删除 {_relative(path)}")
shutil.rmtree(path)
def _ensure_inside_project(path: Path) -> Path:
"""确认目标路径位于项目目录内,并返回解析后的绝对路径。"""
resolved = path.resolve()
root = PROJECT_ROOT.resolve()
if resolved == root or root not in resolved.parents:
raise RuntimeError(f"拒绝操作项目目录之外的路径:{resolved}")
return resolved
def _remove_previous_output(args: argparse.Namespace) -> bool:
"""提前清理旧输出文件;若文件被占用,给出明确提示并停止打包。"""
if not args.onefile:
return True
exe_path = DIST_DIR / f"{EXE_NAME}.exe"
_ensure_inside_project(exe_path)
if not exe_path.exists():
return True
print("\n[清理旧文件]")
try:
exe_path.unlink()
except PermissionError:
print(
f"❌ 错误:无法覆盖旧文件 {exe_path}\n"
"💡 提示:请先关闭正在运行的 BitWave.exe,并确认资源管理器、杀毒软件或同步软件没有占用该文件,"
"然后重新执行打包命令。",
file=sys.stderr,
)
return False
except OSError as exc:
print(f"❌ 错误:无法删除旧输出文件 {exe_path}\n详细错误:{exc}", file=sys.stderr)
return False
print(f"✅ 已成功删除旧输出文件:{exe_path}")
return True
def _find_icon() -> Path | None:
"""如果 gui/assets 下存在 .ico 文件,则使用找到的第一个图标。"""
assets_dir = PROJECT_ROOT / "gui" / "assets"
if not assets_dir.exists():
return None
icons = sorted(assets_dir.rglob("*.ico"))
return icons[0] if icons else None
def _module_exists(name: str) -> bool:
"""判断可选依赖是否已安装,避免 Nuitka 因包含不存在的模块而中断。"""
try:
return importlib.util.find_spec(name) is not None
except ImportError:
return False
def _optional_include_args() -> list[str]:
"""生成导出功能所需的可选依赖包含参数。"""
args = []
# Matplotlib 的保存后端在运行时按格式动态加载,显式包含可避免打包后导出失败。
for package in ("matplotlib", "PIL"):
if _module_exists(package):
args.append(f"--include-package={package}")
for module in (
"matplotlib.backends.backend_agg",
"matplotlib.backends.backend_pdf",
"matplotlib.backends.backend_svg",
"PIL.Image",
"PIL.ImageTk",
"PIL.ImageDraw",
):
if _module_exists(module):
args.append(f"--include-module={module}")
# EMF 导出依赖 PowerPoint COM;没有安装 pywin32 时不强制包含,避免影响其他格式打包。
for module in ("win32com", "win32com.client", "pythoncom", "pywintypes"):
if _module_exists(module):
args.append(f"--include-module={module}")
return args
def _build_command(args: argparse.Namespace, passthrough: list[str]) -> list[str]:
"""根据当前项目配置生成 Nuitka 命令行参数。"""
windows_version = _windows_version(METADATA.app_version)
command = [
sys.executable,
"-m",
"nuitka",
"--standalone",
"--assume-yes-for-downloads",
f"--output-dir={DIST_DIR}",
f"--output-filename={EXE_NAME}.exe",
"--enable-plugin=tk-inter",
"--enable-plugin=matplotlib",
"--include-package=gui",
"--include-package=encoders",
]
command.extend(_optional_include_args())
# 默认打包为单文件,发布时更方便;传入 --no-onefile 可保留展开目录。
if args.onefile:
command.insert(4, "--onefile")
if sys.platform.startswith("win"):
console_mode = "attach" if args.console else "disable"
command.append(f"--windows-console-mode={console_mode}")
icon = _find_icon()
if icon is not None:
command.append(f"--windows-icon-from-ico={icon}")
# 这些信息会显示在 exe 文件“属性 -> 详细信息”页面中。
command.extend(
[
f"--windows-company-name={METADATA.company}",
f"--windows-product-name={METADATA.app_name}",
f"--windows-file-version={windows_version}",
f"--windows-product-version={windows_version}",
f"--windows-file-description={METADATA.app_name} {METADATA.app_version}",
f"--copyright={METADATA.copyright}",
]
)
assets_dir = PROJECT_ROOT / "gui" / "assets"
if assets_dir.exists():
command.append(f"--include-data-dir={assets_dir}=gui/assets")
# 调试构建保留更多 Nuitka 输出,便于定位打包问题。
if args.debug:
command.extend(["--show-progress", "--show-memory", "--debug"])
else:
command.append("--remove-output")
command.extend(passthrough)
command.append(str(ENTRY_FILE))
return command
def parse_args() -> tuple[argparse.Namespace, list[str]]:
"""解析打包脚本参数,并将 -- 后面的参数继续传递给 Nuitka。"""
parser = argparse.ArgumentParser(
description="使用 Nuitka 将 BitWave 打包为 Windows 可执行文件。",
add_help=False,
)
parser.add_argument(
"-h",
"--help",
action="help",
help="显示帮助信息并退出",
)
parser.add_argument(
"--version",
action="version",
version=f"{EXE_NAME} {METADATA.app_version}",
help="显示从 config.py 读取的版本号并退出",
)
parser.add_argument(
"--clean",
action="store_true",
help="打包前删除本地 build 和 dist 目录",
)
parser.add_argument(
"--console",
action="store_true",
help="保留可执行文件运行时的控制台窗口",
)
parser.add_argument(
"--debug",
action="store_true",
help="启用 Nuitka 调试输出,并保留生成的构建文件",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="只打印 Nuitka 命令,不真正执行打包",
)
parser.add_argument(
"--no-onefile",
dest="onefile",
action="store_false",
help="构建独立目录,而不是单个 exe 文件",
)
parser.set_defaults(onefile=True)
if "--" not in sys.argv:
return parser.parse_args(), []
delimiter = sys.argv.index("--")
known_args = parser.parse_args(sys.argv[1:delimiter])
passthrough = sys.argv[delimiter + 1 :]
return known_args, passthrough
def main() -> int:
"""检查入口文件、按需清理输出目录,然后执行 Nuitka 打包。"""
_configure_console_encoding()
args, passthrough = parse_args()
print(f"[{EXE_NAME} 打包工具]")
print("-" * 40)
if not ENTRY_FILE.exists():
print(f"❌ 错误:未找到入口文件 {ENTRY_FILE},请确保在项目根目录下执行。", file=sys.stderr)
return 1
if not _module_exists("nuitka"):
print("❌ 错误:未安装 Nuitka,请先执行命令:pip install nuitka", file=sys.stderr)
return 1
print("✅ 依赖检查通过,准备构建打包命令...")
command = _build_command(args, passthrough)
print("\n[打包配置]")
print(f"应用名称:{METADATA.app_name}")
print(f"版本号:{METADATA.app_version} ({_windows_version(METADATA.app_version)})")
if args.dry_run:
print("\n✅ 已启用 dry-run,仅打印命令,不执行打包。")
print("执行命令:")
print(" ".join(f'"{part}"' if " " in part else part for part in command))
return 0
if not _remove_previous_output(args):
return 1
if args.clean:
print("\n[清理缓存]")
_remove_dir(BUILD_DIR)
_remove_dir(DIST_DIR)
print("\n🚀 开始运行 Nuitka 打包过程(这可能需要几分钟,请耐心等待)...")
print("执行命令:")
print(" ".join(f'"{part}"' if " " in part else part for part in command))
print("-" * 40)
try:
subprocess.run(command, cwd=PROJECT_ROOT, check=True)
except FileNotFoundError:
print("\n❌ 严重错误:未找到 Python 可执行文件,无法调用 Nuitka。", file=sys.stderr)
return 1
except subprocess.CalledProcessError as exc:
print(f"\n❌ 打包失败!Nuitka 进程异常退出,错误码:{exc.returncode}。", file=sys.stderr)
print("💡 提示:请向上滚动查看 Nuitka 输出的具体报错信息(如缺少 C 编译器、代码语法错误等)。", file=sys.stderr)
return exc.returncode
except KeyboardInterrupt:
print("\n⚠️ 打包被用户强制中断。", file=sys.stderr)
return 1
except Exception as exc:
print(f"\n❌ 发生未知异常:{exc}", file=sys.stderr)
return 1
exe_path = DIST_DIR / f"{EXE_NAME}.exe"
print("\n🎉 打包成功!")
if exe_path.exists():
print(f"📄 可执行文件位置:{exe_path}")
else:
print(f"📂 输出目录位置:{DIST_DIR}")
return 0
if __name__ == "__main__":
raise SystemExit(main())