227 lines
10 KiB
Python
227 lines
10 KiB
Python
"""用 Nuitka 打包(真编译成 C 再编译成 exe)。
|
||
|
||
用法::
|
||
|
||
uv run python packaging/build_nuitka.py # 单文件(默认)
|
||
uv run python packaging/build_nuitka.py --standalone # 目录模式
|
||
uv run python packaging/build_nuitka.py --console # 带控制台,排查启动问题
|
||
uv run python packaging/build_nuitka.py --msvc # 指定 MSVC(默认自动挑)
|
||
uv run python packaging/build_nuitka.py --low-memory # 编译器内存不够时用
|
||
uv run python packaging/build_nuitka.py --clean
|
||
uv run python packaging/build_nuitka.py --no-selftest
|
||
|
||
产物:``dist/nuitka/``
|
||
|
||
与 PyInstaller 的区别见 ``packaging/README.md``:Nuitka 构建慢得多、需要一个
|
||
C 编译器,换来的是**启动更快、体积更小、更难被反编译**。
|
||
|
||
构建前后同样有"私钥守卫 + 产物自检"两道关卡(与 PyInstaller 脚本一致)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import os
|
||
import shutil
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||
import _common as common # noqa: E402
|
||
|
||
# 明显用不到的重型模块,让 Nuitka 不要跟进去(Qt 的裁减由 pyside6 插件负责)
|
||
NOFOLLOW = [
|
||
"tkinter",
|
||
"PySide6.QtWebEngineCore",
|
||
"PySide6.QtWebEngineWidgets",
|
||
"PySide6.QtWebEngineQuick",
|
||
"PySide6.Qt3DCore",
|
||
"PySide6.Qt3DRender",
|
||
"PySide6.Qt3DExtras",
|
||
"PySide6.QtCharts",
|
||
"PySide6.QtDataVisualization",
|
||
"PySide6.QtQuick",
|
||
"PySide6.QtQml",
|
||
"PySide6.QtMultimedia",
|
||
"PySide6.QtDesigner",
|
||
"PySide6.QtPdf",
|
||
]
|
||
|
||
# PostgreSQL 驱动(可选功能)。在 Python 3.13+ 上 Nuitka 只能用 MSVC,而 MSVC
|
||
# 编译 psycopg.errors 生成的 2.8 万行 C 文件时会 C1002(编译器堆空间不足)——
|
||
# 这是编译器上限,不是代码问题。因此提供"不带 PG"的构建:界面会如实提示
|
||
# "未安装 PostgreSQL 驱动(可选功能)",其余功能完全不受影响。
|
||
POSTGRES_MODULES = ["psycopg", "psycopg_binary", "psycopg_c", "psycopg_pool"]
|
||
|
||
|
||
def parse_args() -> argparse.Namespace:
|
||
parser = argparse.ArgumentParser(description="用 Nuitka 打包 chongming 授权工具")
|
||
mode = parser.add_mutually_exclusive_group()
|
||
mode.add_argument("--onefile", action="store_true", default=True,
|
||
help="打成单个 exe(默认)")
|
||
mode.add_argument("--standalone", action="store_true",
|
||
help="打成目录(启动更快)")
|
||
compiler = parser.add_mutually_exclusive_group()
|
||
compiler.add_argument("--msvc", action="store_true",
|
||
help="用 MSVC 编译(默认自动挑选编译器)")
|
||
compiler.add_argument("--mingw64", action="store_true",
|
||
help="用 MinGW64 编译(默认自动挑选编译器)")
|
||
parser.add_argument("--jobs", type=int, default=0,
|
||
help="并行编译任务数(默认交给 Nuitka 决定)")
|
||
parser.add_argument("--low-memory", action="store_true", default=True,
|
||
help="降低编译器内存占用(默认开启)")
|
||
parser.add_argument("--no-low-memory", dest="low_memory", action="store_false",
|
||
help="关闭低内存模式。默认开启是有原因的:本项目的代码经 Nuitka "
|
||
"展开后,MSVC 编译最大的那个模块会因堆空间不足报 C1002/LNK1257")
|
||
parser.add_argument("--lto", choices=("yes", "no"), default=None,
|
||
help="链接期优化。默认交给 Nuitka(开着,链接很慢);"
|
||
"--lto=no 可显著缩短构建时间,产物略大")
|
||
parser.add_argument("--no-postgres", dest="postgres", action="store_false",
|
||
default=True,
|
||
help="构建不带 PostgreSQL 支持的版本。Python 3.13+ 上 "
|
||
"Nuitka 只能用 MSVC,而 MSVC 编译 psycopg.errors 会 C1002;"
|
||
"此时工具会提示「未安装驱动」,本地台账照常可用")
|
||
parser.add_argument("--console", action="store_true",
|
||
help="保留控制台窗口(默认无控制台)")
|
||
parser.add_argument("--clean", action="store_true",
|
||
help="构建前清理并删除中间产物")
|
||
parser.add_argument("--no-selftest", action="store_true", help="跳过构建后的产物自检")
|
||
parser.add_argument("--allow-sensitive-files", action="store_true",
|
||
help="源码树里有私钥/.lic/台账库时仍继续构建(默认中止)")
|
||
return parser.parse_args()
|
||
|
||
|
||
def build_command(args: argparse.Namespace, *, icon: Path | None, onefile: bool,
|
||
low_memory: bool, with_postgres: bool = True) -> list[str]:
|
||
dist_dir = common.DIST / "nuitka"
|
||
version = common.project_version()
|
||
command = [
|
||
sys.executable, "-m", "nuitka",
|
||
f"--mode={'onefile' if onefile else 'standalone'}",
|
||
# PySide6 插件负责把 Qt 的 DLL 与插件(platforms/styles/imageformats/
|
||
# iconengines)收集齐全 —— 少了 imageformats 里的 svg,图标与复选框
|
||
# 指示器会变空白(构建后的自检会验证这一点)
|
||
"--enable-plugins=pyside6",
|
||
# 注意:Nuitka 的值型选项必须写成 --opt=value,空格分隔会报
|
||
# "requires an argument with '--output-dir='"
|
||
f"--output-dir={dist_dir}",
|
||
f"--output-filename={common.APP_NAME}.exe",
|
||
f"--windows-console-mode={'force' if args.console else 'disable'}",
|
||
# 不下载 ccache / depends.exe 之类就别想跑通,脚本里没法应答交互提示
|
||
"--assume-yes-for-downloads",
|
||
# exe 属性:公司/产品/版本(右键属性里看得到)
|
||
"--company-name=chongming",
|
||
f"--product-name={common.APP_NAME}",
|
||
f"--file-version={version}",
|
||
f"--product-version={version}",
|
||
"--file-description=chongming 机器码授权工具(厂商侧)",
|
||
]
|
||
if icon:
|
||
command.append(f"--windows-icon-from-ico={icon}")
|
||
if args.msvc:
|
||
command.append("--msvc=latest")
|
||
elif args.mingw64:
|
||
command.append("--mingw64")
|
||
if args.jobs > 0:
|
||
command.append(f"--jobs={args.jobs}")
|
||
if args.lto:
|
||
command.append(f"--lto={args.lto}")
|
||
if low_memory:
|
||
# Nuitka 生成的 C 文件极大(单个模块上万行),MSVC 会因堆空间不足报
|
||
# C1002/LNK1257。这个开关让 Nuitka 少塞内联代码、把模块拆得更细。
|
||
command.append("--low-memory")
|
||
if args.clean:
|
||
command.append("--remove-output")
|
||
for module in NOFOLLOW:
|
||
command.append(f"--nofollow-import-to={module}")
|
||
if not with_postgres:
|
||
for module in POSTGRES_MODULES:
|
||
command.append(f"--nofollow-import-to={module}")
|
||
command.append(str(common.PACKAGING / "launcher.py"))
|
||
return command
|
||
|
||
|
||
def main() -> int:
|
||
args = parse_args()
|
||
common.require("nuitka", "nuitka")
|
||
|
||
onefile = not args.standalone
|
||
dist_dir = common.DIST / "nuitka"
|
||
log_path = common.PACKAGING / "build" / "nuitka-build.log"
|
||
|
||
print("=" * 60)
|
||
print(f"Nuitka 打包({'单文件' if onefile else '目录模式'})")
|
||
print("=" * 60)
|
||
|
||
# 1) 安全闸门
|
||
common.guard_no_secrets(allow=args.allow_sensitive_files)
|
||
|
||
# 2) 图标
|
||
icon = common.build_app_icon()
|
||
|
||
if args.clean and dist_dir.exists():
|
||
shutil.rmtree(dist_dir)
|
||
print(f"已清理 {dist_dir.relative_to(common.ROOT)}")
|
||
|
||
# 3) 构建(首次很慢,日志落盘)
|
||
env = dict(os.environ)
|
||
# 让 Nuitka 能解析本包:直接给源码树,不依赖 editable 安装
|
||
env["PYTHONPATH"] = str(common.ROOT / "src") + os.pathsep + env.get("PYTHONPATH", "")
|
||
# 与 PyInstaller 同理:PATH 里的同名 DLL 会被误收集(见 _common 的说明)
|
||
env.update(common.build_env_overrides(env))
|
||
|
||
low_memory = args.low_memory
|
||
with_postgres = args.postgres
|
||
command = build_command(args, icon=icon, onefile=onefile, low_memory=low_memory,
|
||
with_postgres=with_postgres)
|
||
print("\n$ " + " ".join(command))
|
||
print("\n提示:Nuitka 是**真编译**,首次构建约 10 分钟起(后续有缓存会快很多)。")
|
||
print(f"构建日志:{log_path.relative_to(common.ROOT)}\n")
|
||
|
||
started = time.monotonic()
|
||
code, log_text = common.run_build(command, log_path, env)
|
||
|
||
# 兜底:万一还是撞上 MSVC 堆空间不足(例如用了 --no-low-memory),自动降内存重试
|
||
if code != 0 and not low_memory and common.is_compiler_out_of_memory(log_text):
|
||
print("检测到编译器堆空间不足(C1002/LNK1257)—— 自动加 --low-memory 重试一次…\n")
|
||
low_memory = True
|
||
command = build_command(args, icon=icon, onefile=onefile, low_memory=True)
|
||
code, log_text = common.run_build(command, log_path, env)
|
||
|
||
elapsed = time.monotonic() - started
|
||
if code != 0:
|
||
print(f"❌ Nuitka 构建失败(退出码 {code})\n")
|
||
print(common.log_tail(log_text, 30))
|
||
print(f"\n完整日志:{log_path.relative_to(common.ROOT)}")
|
||
if common.is_compiler_out_of_memory(log_text):
|
||
print("失败原因是编译器内存不足。先用 --low-memory(默认已开);"
|
||
"仍失败就改用 PyInstaller(build_pyinstaller.py),"
|
||
"或用 --no-postgres 构建不带 PG 的版本。")
|
||
else:
|
||
print("常见原因:缺少 C 编译器。Windows 上装 Visual Studio 的「C++ 生成工具」,")
|
||
print("或用 --mingw64(需 Nuitka 支持的 mingw64 版本,msys2 自带的那个不行)。")
|
||
return code
|
||
|
||
# 4) 找到产物并验证
|
||
artifact = common.find_artifact(dist_dir)
|
||
if artifact is None:
|
||
print(f"\n❌ 构建结束但没在 {dist_dir} 里找到 exe")
|
||
return 1
|
||
|
||
stray = common.guard_artifact_no_secrets(artifact.parent)
|
||
if stray:
|
||
print(f"\n❌ 产物目录里出现敏感文件,请检查后再分发:{stray}")
|
||
return 1
|
||
|
||
if not args.no_selftest:
|
||
if not common.verify_artifact(artifact, "Nuitka 产物"):
|
||
print("\n❌ 产物自检未通过 —— 先别分发,见上方输出")
|
||
return 1
|
||
|
||
common.report_success("Nuitka", artifact, elapsed)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|