build.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. laser_cal 项目构建脚本
  5. 集成 CodeEnigma 混淆 + Nuitka 编译
  6. 支持三种编译模式:
  7. python build.py --module # 编译为共享库 (.so / .pyd)
  8. python build.py --exe # 编译为 standalone 可执行程序目录(推荐)
  9. python build.py --onefile # 编译为单个可执行文件
  10. 其他选项:
  11. python build.py --skip-obfuscate # 跳过 CodeEnigma 混淆步骤
  12. python build.py --no-license # 不使用 license 文件授权(打包为无授权版本)
  13. python build.py --clean # 清理构建缓存
  14. """
  15. import os
  16. import sys
  17. import shutil
  18. import subprocess
  19. import argparse
  20. import platform
  21. import tempfile
  22. from pathlib import Path
  23. BASE_DIR = Path(__file__).parent.absolute()
  24. DIST_DIR = BASE_DIR / "dist" / "program"
  25. LICENSE_DIR = BASE_DIR / "dist" / "license"
  26. OBFUSCATED_DIR = BASE_DIR / "dist" / "obfuscated"
  27. BUILD_SRC_DIR = BASE_DIR / "dist" / "_build_src"
  28. PROJECT_NAME = "laser_cal"
  29. FILES_TO_OBFUSCATE = ["data_clean.py", "frequency_filter.py"]
  30. LOCAL_MODULES = ["license_checker", "data_clean", "frequency_filter", "entry"]
  31. PACKAGES_TO_INCLUDE = ["pandas", "numpy", "scipy", "cryptography"]
  32. def log(msg, level="INFO"):
  33. print(f"[{level}] {msg}")
  34. def run_cmd(cmd, cwd=None, env=None, allow_fail=False):
  35. log(f"执行: {' '.join(str(c) for c in cmd)}")
  36. result = subprocess.run([str(c) for c in cmd], cwd=str(cwd or BASE_DIR), env=env)
  37. if result.returncode != 0 and not allow_fail:
  38. log(f"命令失败,退出码: {result.returncode}", "ERROR")
  39. sys.exit(1)
  40. return result
  41. def ensure_nuitka():
  42. try:
  43. subprocess.run([sys.executable, "-m", "nuitka", "--version"],
  44. capture_output=True, check=True)
  45. except (FileNotFoundError, subprocess.CalledProcessError):
  46. log("安装 Nuitka...")
  47. run_cmd([sys.executable, "-m", "pip", "install",
  48. "nuitka", "ordered-set", "zstandard", "--quiet"])
  49. # ============================================================
  50. # 步骤 1: CodeEnigma 混淆(修复版:混淆失败不中断构建)
  51. # ============================================================
  52. def step_obfuscate():
  53. log("=" * 60)
  54. log("步骤 1: CodeEnigma 代码混淆")
  55. log("=" * 60)
  56. # 确保 codeenigma 已安装
  57. try:
  58. subprocess.run(["codeenigma", "version"], capture_output=True, check=True)
  59. except (FileNotFoundError, subprocess.CalledProcessError):
  60. log("安装 codeenigma...")
  61. run_cmd([sys.executable, "-m", "pip", "install", "codeenigma", "--quiet"])
  62. if OBFUSCATED_DIR.exists():
  63. shutil.rmtree(OBFUSCATED_DIR)
  64. OBFUSCATED_DIR.mkdir(parents=True)
  65. with tempfile.TemporaryDirectory(dir=BASE_DIR / "dist", prefix="_tmp_mod_") as tmp_dir:
  66. tmp_path = Path(tmp_dir)
  67. for py_file in FILES_TO_OBFUSCATE:
  68. src = BASE_DIR / py_file
  69. if src.exists():
  70. shutil.copy2(src, tmp_path / py_file)
  71. log(f" 准备混淆: {py_file}")
  72. else:
  73. log(f" 文件不存在: {py_file}", "WARN")
  74. (tmp_path / "__init__.py").touch()
  75. # 执行混淆(允许失败)
  76. result = run_cmd(
  77. ["codeenigma", "obfuscate", str(tmp_path), "-o", str(OBFUSCATED_DIR)],
  78. allow_fail=True
  79. )
  80. if result.returncode != 0:
  81. log("CodeEnigma 报告错误,尝试自动恢复...", "WARN")
  82. # 1. 尝试从子目录恢复混淆后的 .py 文件
  83. for py_file in FILES_TO_OBFUSCATE:
  84. target = OBFUSCATED_DIR / py_file
  85. if not target.exists():
  86. found = list(OBFUSCATED_DIR.rglob(py_file))
  87. if found:
  88. # 取第一个找到的文件(通常是 build 目录下的副本)
  89. shutil.copy2(found[0], target)
  90. log(f" 从 {found[0].relative_to(OBFUSCATED_DIR)} 恢复混淆文件: {py_file}")
  91. else:
  92. log(f" 未找到混淆后的 {py_file},稍后将回退到原始文件", "WARN")
  93. # 2. 恢复运行时库
  94. runtime_pyd = None
  95. search_dir = OBFUSCATED_DIR / "build"
  96. if search_dir.exists():
  97. if platform.system() == "Windows":
  98. candidates = list(search_dir.glob("**/*.pyd"))
  99. else:
  100. candidates = list(search_dir.glob("**/*.so"))
  101. candidates = [c for c in candidates if "codeenigma_runtime" in c.name]
  102. if candidates:
  103. runtime_pyd = candidates[0]
  104. log(f" 找到运行时库: {runtime_pyd}")
  105. runtime_dest_dir = OBFUSCATED_DIR / "codeenigma_runtime"
  106. runtime_dest_dir.mkdir(exist_ok=True)
  107. if runtime_pyd and runtime_pyd.exists():
  108. dest = runtime_dest_dir / runtime_pyd.name
  109. if not dest.exists():
  110. shutil.copy2(runtime_pyd, dest)
  111. log(f" 复制运行时库到: {dest}")
  112. else:
  113. log(" 未找到编译后的运行时库,若程序依赖 codeenigma_runtime 将无法运行", "WARN")
  114. # 检查核心混淆文件最终是否可用
  115. core_ok = all((OBFUSCATED_DIR / f).exists() for f in FILES_TO_OBFUSCATE)
  116. if not core_ok:
  117. log("核心混淆文件最终仍缺失,构建将回退使用原始未混淆代码!", "ERROR")
  118. # 不退出,继续构建
  119. else:
  120. log("核心混淆文件已就绪,混淆成功")
  121. # ============================================================
  122. # 步骤 2: 准备编译目录(支持 --no-license,自动回退原始文件)
  123. # ============================================================
  124. def _generate_fake_license_module():
  125. sys.path.insert(0, str(BASE_DIR))
  126. try:
  127. import license_checker
  128. except ImportError:
  129. return "# Auto-generated fake license_checker\ndef check_license(*args, **kwargs): return True\n"
  130. public_names = [name for name in dir(license_checker) if not name.startswith('_')]
  131. if hasattr(license_checker, '__all__'):
  132. public_names = list(license_checker.__all__)
  133. lines = [
  134. "# -*- coding: utf-8 -*-",
  135. "# Auto-generated fake license_checker for no-license builds",
  136. ""
  137. ]
  138. for name in public_names:
  139. obj = getattr(license_checker, name, None)
  140. if callable(obj):
  141. lines.append(f"def {name}(*args, **kwargs):")
  142. lines.append(f" return True")
  143. lines.append("")
  144. else:
  145. lines.append(f"{name} = None")
  146. lines.append("")
  147. sys.path.pop(0)
  148. return "\n".join(lines)
  149. def step_prepare_build_dir(mode="module", no_license=False):
  150. log("=" * 60)
  151. log("步骤 2: 准备编译目录")
  152. log("=" * 60)
  153. if BUILD_SRC_DIR.exists():
  154. shutil.rmtree(BUILD_SRC_DIR)
  155. BUILD_SRC_DIR.mkdir(parents=True)
  156. if mode == "module":
  157. files_to_copy = ["entry.py", "license_checker.py"]
  158. else:
  159. files_to_copy = ["api_test.py", "license_checker.py", "entry.py"]
  160. for f in files_to_copy:
  161. src = BASE_DIR / f
  162. if not src.exists():
  163. continue
  164. if f == "license_checker.py" and no_license:
  165. log(" 生成无授权模块: license_checker.py")
  166. with open(BUILD_SRC_DIR / f, "w", encoding="utf-8") as fout:
  167. fout.write(_generate_fake_license_module())
  168. else:
  169. shutil.copy2(src, BUILD_SRC_DIR / f)
  170. log(f" 复制: {f}")
  171. # 复制混淆后的文件(如果存在),否则回退原始文件
  172. for py_file in FILES_TO_OBFUSCATE:
  173. obf_file = OBFUSCATED_DIR / py_file
  174. if obf_file.exists():
  175. shutil.copy2(obf_file, BUILD_SRC_DIR / py_file)
  176. log(f" 复制混淆文件: {py_file}")
  177. else:
  178. orig_file = BASE_DIR / py_file
  179. if orig_file.exists():
  180. shutil.copy2(orig_file, BUILD_SRC_DIR / py_file)
  181. log(f" 混淆文件缺失,使用原始文件: {py_file}", "WARN")
  182. # 复制运行时库(如果有)
  183. runtime_dir = OBFUSCATED_DIR / "codeenigma_runtime"
  184. if runtime_dir.exists() and any(runtime_dir.iterdir()):
  185. shutil.copytree(runtime_dir, BUILD_SRC_DIR / "codeenigma_runtime")
  186. log(" 复制 codeenigma_runtime")
  187. else:
  188. log(" 未找到 codeenigma_runtime,跳过复制", "WARN")
  189. return BUILD_SRC_DIR
  190. # ============================================================
  191. # 步骤 3: Nuitka 编译
  192. # ============================================================
  193. def step_nuitka_module(build_src):
  194. log("=" * 60)
  195. log("步骤 3: Nuitka 编译为共享库 (.so / .pyd)")
  196. log("=" * 60)
  197. ensure_nuitka()
  198. cmd = [
  199. sys.executable, "-m", "nuitka",
  200. "--module",
  201. "--follow-import-to=license_checker",
  202. "--follow-import-to=data_clean",
  203. "--follow-import-to=frequency_filter",
  204. "--follow-import-to=entry",
  205. f"--output-dir={DIST_DIR}",
  206. "--python-flag=no_docstrings",
  207. "--python-flag=-O",
  208. "--remove-output",
  209. "--assume-yes-for-downloads",
  210. ]
  211. runtime_dir = build_src / "codeenigma_runtime"
  212. if runtime_dir.exists():
  213. for so_file in list(runtime_dir.glob("*.so")) + list(runtime_dir.glob("*.pyd")):
  214. cmd.append(f"--include-data-files={so_file}=codeenigma_runtime/{so_file.name}")
  215. cmd.append(str(build_src / "entry.py"))
  216. env = os.environ.copy()
  217. if platform.system() == "Linux":
  218. env["CC"] = "gcc"
  219. run_cmd(cmd, cwd=build_src, env=env)
  220. log(f"共享库已生成到: {DIST_DIR}")
  221. def step_nuitka_exe(build_src, onefile=False):
  222. mode_name = "单文件可执行程序" if onefile else "standalone 可执行程序"
  223. log("=" * 60)
  224. log(f"步骤 3: Nuitka 编译为{mode_name}")
  225. log("=" * 60)
  226. ensure_nuitka()
  227. output_dir = DIST_DIR
  228. cmd = [sys.executable, "-m", "nuitka"]
  229. if onefile:
  230. cmd.append("--onefile")
  231. else:
  232. cmd.append("--standalone")
  233. cmd.extend([
  234. "--follow-imports",
  235. f"--output-dir={output_dir}",
  236. "--python-flag=no_docstrings",
  237. "--python-flag=-O",
  238. "--remove-output",
  239. "--assume-yes-for-downloads",
  240. ])
  241. for pkg in PACKAGES_TO_INCLUDE:
  242. cmd.append(f"--include-package={pkg}")
  243. cmd += [
  244. "--include-module=license_checker",
  245. "--include-module=data_clean",
  246. "--include-module=frequency_filter",
  247. ]
  248. runtime_dir = build_src / "codeenigma_runtime"
  249. if runtime_dir.exists():
  250. cmd.append(f"--include-data-dir={runtime_dir}=codeenigma_runtime")
  251. if platform.system() == "Windows":
  252. cmd.append(f"--output-filename={PROJECT_NAME}.exe")
  253. else:
  254. cmd.append(f"--output-filename={PROJECT_NAME}")
  255. cmd.append(str(build_src / "api_test.py"))
  256. env = os.environ.copy()
  257. if platform.system() == "Linux":
  258. env["CC"] = "gcc"
  259. run_cmd(cmd, cwd=build_src, env=env)
  260. if onefile:
  261. exe_name = PROJECT_NAME + ".exe" if platform.system() == "Windows" else PROJECT_NAME
  262. log(f"可执行文件已生成: {output_dir / exe_name}")
  263. else:
  264. log(f"可执行程序目录已生成: {output_dir / 'api_test.dist'}")
  265. # ============================================================
  266. # 步骤 4: 复制 License(根据 --no-license 跳过)
  267. # ============================================================
  268. def step_copy_license(mode="module", onefile=False, no_license=False):
  269. if no_license:
  270. log("=" * 60)
  271. log("步骤 4: 跳过 License 文件复制(无授权模式)")
  272. log("=" * 60)
  273. return
  274. log("=" * 60)
  275. log("步骤 4: 复制 License 文件")
  276. log("=" * 60)
  277. if not LICENSE_DIR.exists():
  278. log(" License 目录不存在,跳过", "WARN")
  279. return
  280. if mode == "module":
  281. target = DIST_DIR
  282. elif onefile:
  283. target = DIST_DIR
  284. else:
  285. target = DIST_DIR / "api_test.dist"
  286. target.mkdir(parents=True, exist_ok=True)
  287. for f in ["license.lic", "public_key.pem"]:
  288. src = LICENSE_DIR / f
  289. if src.exists():
  290. shutil.copy2(src, target / f)
  291. log(f" 复制: {f}")
  292. # ============================================================
  293. # 清理
  294. # ============================================================
  295. def clean():
  296. log("清理构建缓存...")
  297. for d in [DIST_DIR, OBFUSCATED_DIR, BUILD_SRC_DIR]:
  298. if d.exists():
  299. shutil.rmtree(d)
  300. log(f" 删除: {d}")
  301. # ============================================================
  302. # 主函数
  303. # ============================================================
  304. def main():
  305. parser = argparse.ArgumentParser(description="laser_cal 项目构建脚本 (CodeEnigma + Nuitka)",
  306. formatter_class=argparse.RawDescriptionHelpFormatter,
  307. epilog="""
  308. 编译模式说明:
  309. --module 编译为共享库 (.so/.pyd),需要 Python 环境 + Node.js wrapper 调用
  310. --exe 编译为 standalone 可执行程序目录,Node.js 可直接 execFile 调用
  311. --onefile 编译为单个可执行文件,最便于分发
  312. 示例:
  313. python build.py --exe 混淆 + 编译为可执行程序
  314. python build.py --exe --skip-obfuscate 仅编译为可执行程序
  315. python build.py --exe --no-license 混淆 + 编译为无授权可执行程序
  316. python build.py --module --skip-obfuscate 仅编译为共享库
  317. python build.py --onefile 混淆 + 编译为单文件
  318. python build.py --clean 清理构建缓存
  319. """)
  320. mode_group = parser.add_mutually_exclusive_group()
  321. mode_group.add_argument("--module", action="store_true")
  322. mode_group.add_argument("--exe", action="store_true")
  323. mode_group.add_argument("--onefile", action="store_true")
  324. parser.add_argument("--skip-obfuscate", action="store_true")
  325. parser.add_argument("--no-license", action="store_true")
  326. parser.add_argument("--clean", action="store_true")
  327. args = parser.parse_args()
  328. if args.clean:
  329. clean()
  330. return
  331. if not args.module and not args.exe and not args.onefile:
  332. args.module = True
  333. mode = "module" if args.module else ("exe" if args.exe else "onefile")
  334. log("=" * 60)
  335. log(f"laser_cal 构建脚本")
  336. log(f" 编译模式: {mode}")
  337. log(f" 代码混淆: {'是' if not args.skip_obfuscate else '否'}")
  338. log(f" 授权验证: {'否(无授权模式)' if args.no_license else '是(需 license 文件)'}")
  339. log(f" 操作系统: {platform.system()} ({platform.machine()})")
  340. log(f" Python: {sys.version.split()[0]}")
  341. log("=" * 60)
  342. # 混淆步骤(失败也不中断,会自动回退原始代码)
  343. if not args.skip_obfuscate:
  344. step_obfuscate()
  345. # 准备编译目录
  346. if not args.skip_obfuscate and OBFUSCATED_DIR.exists():
  347. build_src = step_prepare_build_dir(mode=mode, no_license=args.no_license)
  348. else:
  349. build_src = BASE_DIR
  350. # 编译
  351. if mode == "module":
  352. step_nuitka_module(build_src)
  353. elif mode == "exe":
  354. step_nuitka_exe(build_src, onefile=False)
  355. else:
  356. step_nuitka_exe(build_src, onefile=True)
  357. # 复制 License(无授权则跳过)
  358. step_copy_license(mode=mode, onefile=(mode == "onefile"), no_license=args.no_license)
  359. log("=" * 60)
  360. log("构建完成!")
  361. log(f" 输出目录: {DIST_DIR}")
  362. if args.no_license:
  363. log(" 授权状态: 无授权版本(无需 license.lic / public_key.pem)")
  364. if mode == "module":
  365. log(" 调用方式: Node.js -> laser_wrapper.py -> entry.pyd/.so")
  366. else:
  367. exe_name = PROJECT_NAME + (".exe" if platform.system() == "Windows" else "")
  368. log(f" 调用方式: Node.js -> child_process.execFile('{exe_name}', [api, data])")
  369. log("=" * 60)
  370. if __name__ == "__main__":
  371. main()