248 lines
10 KiB
Python
248 lines
10 KiB
Python
"""通过 Nuitka 把 mil 包及其纯 Python 伴生包编译为 .pyd。
|
||
|
||
策略说明:
|
||
- **mil 业务包**(mil/ 整个包:core + ui)→ 编 pyd;
|
||
- **Qt 桥接代码**(mil/ui/*_ui.py、main_ui.py)→ 保留为源码;
|
||
- **纯 Python 伴生包**(COMPANIONS 列表)→ 逐个编 pyd,产物随 mil 一起分发;
|
||
- **Qt .ui 资源文件** → 运行时由 mil 包内的 uic.loadUiType 直接读源码路径;
|
||
- **PySide6** → --nofollow-import-to,运行时走宿主 pip 安装的二进制。
|
||
|
||
前置条件:
|
||
- openpyxl 3.x 的 C 扩展依赖 et_xmlfile 必须升到 >= 2.0(纯 Python 实现),
|
||
否则 Nuitka --module 模式会丢弃 _xmlfile.pyd 导致运行期 ImportError。
|
||
pip install --upgrade --force-reinstall "et_xmlfile>=2.0"
|
||
pip install --upgrade --force-reinstall openpyxl
|
||
|
||
用法(在工程根 F:\MyProject\mil_sdk 下):
|
||
python build_pyd.py # 编译 + 导出 + 打包 + 部署(默认全流程)
|
||
python build_pyd.py --clean # 清理 build/ 后走全流程
|
||
python build_pyd.py --no-progress # 关闭进度条(CI 友好)
|
||
python build_pyd.py --only mil # 只编指定包(逗号分隔)
|
||
python build_pyd.py --no-deploy # 不复制 zip 到网络盘
|
||
|
||
全流程四步:
|
||
1. Nuitka 编译 .pyd → build/
|
||
2. export_runtime 聚合 → plugins/
|
||
3. pack_zip 打包 → dist/<tool>-<ver>-<tag>.zip
|
||
4. 复制 zip → Y:/SE/xufeifei/plugins/(--no-deploy 跳过)
|
||
|
||
产物布局:
|
||
build/
|
||
mil.cp311-win_amd64.pyd ← 业务包
|
||
plugins/ ← export_runtime.py 生成(中间产物)
|
||
dist/
|
||
mil-<version>-cp311-win_amd64.zip ← pack_zip.py 生成(最终交付物)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parent
|
||
BUILD_DIR = ROOT / "build"
|
||
DIST_DIR = ROOT / "dist"
|
||
REMOTE_DEPLOY_DIR = Path("Y:/SE/xufeifei/plugins")
|
||
|
||
|
||
def _resolve_entry(pkg: str) -> Path:
|
||
"""定位包的入口 __init__.py。
|
||
|
||
业务包(mil)从工程根找;第三方包从当前 Python 的 site-packages 找。
|
||
"""
|
||
# 1) 业务包:从工程根找(mil 与 build_pyd.py 同级)
|
||
local = ROOT / pkg / "__init__.py"
|
||
if local.is_file():
|
||
return local
|
||
|
||
# 2) 第三方包:从当前解释器的 site-packages 找
|
||
try:
|
||
import importlib.util
|
||
spec = importlib.util.find_spec(pkg)
|
||
if spec is not None and spec.origin:
|
||
origin = Path(spec.origin)
|
||
if origin.is_file():
|
||
return origin
|
||
except (ImportError, ValueError):
|
||
pass
|
||
|
||
raise FileNotFoundError(f"找不到包 {pkg} 的入口 __init__.py")
|
||
|
||
# 要打成 .pyd 的纯 Python 伴生包清单(必须无 .pyd / .so / .dylib)。
|
||
# 从 manifest 读取,保持唯一事实源。
|
||
# openpyxl/et_xmlfile 改为 .py 源码分发(Nuitka .pyd 对 stdlib import 有 hard-import
|
||
# 优化,运行时不查 sys.path,导致插件自带的 stdlib xml 找不到)。
|
||
import importlib.util as _ilu
|
||
_spec = _ilu.spec_from_file_location("mil_manifest", ROOT / "mil" / "core" / "manifest.py")
|
||
_mod = _ilu.module_from_spec(_spec)
|
||
_spec.loader.exec_module(_mod)
|
||
COMPANIONS: list[str] = _mod.COMPANION_PACKAGES_PYD
|
||
|
||
|
||
def build_one(pkg: str, no_progress: bool) -> Path:
|
||
"""对单个包跑 Nuitka --module,产出 <pkg>.<python-tag>.pyd。
|
||
|
||
区分业务包与第三方伴生包:
|
||
- 业务包(mil):保留原有逻辑,让 Nuitka 跟进所有依赖;
|
||
- 第三方包:显式排除 stdlib 子模块,避免"半截包"问题
|
||
(--module 模式物理上编不进 C 加速模块,会留下残缺包导致运行期 ImportError)。
|
||
|
||
关键约束:--module 模式下要编"包"必须传"包目录路径"(末尾带 /),
|
||
不能传 __init__.py。Nuitka 看到目录就会自动按包模式处理。
|
||
"""
|
||
entry = _resolve_entry(pkg) # 入口解析保留,仅用于校验包存在
|
||
pkg_dir = entry.parent
|
||
pkg_name = pkg_dir.name
|
||
is_local = pkg_dir.parent == ROOT # 工程内业务包 vs site-packages 第三方包
|
||
|
||
# 末尾带 / —— Nuitka 区分"目录 vs 文件"的关键
|
||
module_arg = str(pkg_dir) + "/"
|
||
|
||
cmd: list[str] = [
|
||
sys.executable, "-m", "nuitka",
|
||
"--module",
|
||
module_arg, # ← 包目录,不是 __init__.py
|
||
f"--include-package={pkg_name}",
|
||
f"--output-dir={BUILD_DIR}", # 绝对路径,避免被 cwd 影响
|
||
]
|
||
|
||
# 仅对第三方包显式排除 stdlib 子模块,避免半截包问题。
|
||
# 运行时这些 stdlib 走宿主 Python 自带版本(任何合规安装都有)。
|
||
if not is_local:
|
||
cmd += [
|
||
"--nofollow-import-to=xml", # openpyxl/xml/functions.py:40 用到 iterparse
|
||
"--nofollow-import-to=xml.etree",
|
||
"--nofollow-import-to=xml.etree.ElementTree",
|
||
"--nofollow-import-to=zipfile",
|
||
"--nofollow-import-to=tempfile",
|
||
"--nofollow-import-to=datetime",
|
||
"--nofollow-import-to=decimal",
|
||
"--nofollow-import-to=re",
|
||
"--nofollow-import-to=json",
|
||
"--nofollow-import-to=argparse",
|
||
"--nofollow-import-to=logging",
|
||
]
|
||
|
||
# 对业务包(mil):把纯 Python 依赖编进 mil.pyd,C 扩展留给运行时加载。
|
||
#
|
||
# 原理:openpyxl/et_xmlfile/xml.etree 都是纯 Python,Nuitka --module 会把
|
||
# 它们的 .py 代码编进 mil.pyd,运行时 mil.pyd 内部 import 走 hard-import,
|
||
# 不查 sys.path。只有 _elementtree/pyexpat 是 C 扩展,--module 编不进去,
|
||
# 运行时走标准 import 从 plugins/mil/ 加载。
|
||
#
|
||
# 好处:宿主 exe 不需要预知插件依赖哪些 stdlib,真正一次编译永久适用。
|
||
# 新增纯 Python stdlib 依赖时 Nuitka 自动编进 mil.pyd,不需要重编宿主。
|
||
if is_local:
|
||
# 从 manifest 读取要编进 mil.pyd 的纯 Python 包
|
||
for pkg in _mod.COMPANION_PACKAGES_INLINE:
|
||
cmd.append(f"--include-package={pkg}")
|
||
# Nuitka 默认不编译 stdlib,显式 include openpyxl 间接依赖的 stdlib 子模块
|
||
for mod in _mod.COMPANION_STDLIB_INLINE:
|
||
cmd.append(f"--include-module={mod}")
|
||
# C 扩展不编进 .pyd,运行时从插件目录加载
|
||
for ext_pkgs in _mod.COMPANION_C_EXTENSIONS.values():
|
||
for ext in ext_pkgs:
|
||
cmd.append(f"--nofollow-import-to={ext}")
|
||
# pyexpat 是 _elementtree 的底层 C 扩展,也需 nofollow
|
||
cmd.append("--nofollow-import-to=pyexpat")
|
||
|
||
cmd += ["--no-deployment-flag=frame-useless-set-trace"]
|
||
if no_progress:
|
||
cmd.append("--no-progress")
|
||
|
||
# cwd 切到包所在目录的父目录:Nuitka 在此目录下识别包名
|
||
subprocess.run(cmd, cwd=str(pkg_dir.parent), check=True)
|
||
|
||
# 兼容两种产物布局:
|
||
# 1) build/<pkg>.<python-tag>.pyd ← Nuitka --module 默认
|
||
# 2) build/<pkg>/<pkg>.<python-tag>.pyd ← 旧版本 / 某些参数组合
|
||
candidates = list(BUILD_DIR.glob(f"{pkg_name}.*.pyd"))
|
||
if not candidates:
|
||
candidates = list((BUILD_DIR / pkg_name).glob(f"{pkg_name}.*.pyd"))
|
||
if not candidates:
|
||
raise FileNotFoundError(
|
||
f"Nuitka 未产出预期的 {pkg_name}.*.pyd(已扫遍 build/)"
|
||
)
|
||
return candidates[0]
|
||
|
||
|
||
def _export_and_pack() -> None:
|
||
"""编译完成后,串调 export_runtime + pack_zip 生成最终 zip。
|
||
|
||
两个工具脚本都暴露 main(),直接 import 调用,避免 subprocess 开销与
|
||
重复的 importlib 加载 manifest 逻辑。子脚本失败时抛 SystemExit,
|
||
此处捕获并提前返回(后续步骤无意义)。
|
||
"""
|
||
tools_dir = ROOT / "tools"
|
||
if str(tools_dir) not in sys.path:
|
||
sys.path.insert(0, str(tools_dir))
|
||
|
||
try:
|
||
print("[build_pyd] 步骤 2/4: 导出 plugins/ 分发包")
|
||
import export_runtime
|
||
export_runtime.main()
|
||
|
||
print("[build_pyd] 步骤 3/4: 打包 zip")
|
||
import pack_zip
|
||
pack_zip.main()
|
||
except SystemExit as e:
|
||
# 子脚本 sys.exit(1) 表示前置条件不满足(如 build/ 为空),直接中止
|
||
if e.code != 0:
|
||
print(f"[build_pyd] 打包流程中止(退出码 {e.code})", file=sys.stderr)
|
||
return False
|
||
return True
|
||
|
||
|
||
def _deploy_zip() -> None:
|
||
"""把 dist/ 下最新生成的 zip 复制到网络盘远程分发目录。"""
|
||
zips = sorted(DIST_DIR.glob("*.zip"), key=lambda f: f.stat().st_mtime, reverse=True)
|
||
if not zips:
|
||
print("[build_pyd] WARN: dist/ 下无 zip 文件,跳过部署", file=sys.stderr)
|
||
return
|
||
src = zips[0]
|
||
if not REMOTE_DEPLOY_DIR.exists():
|
||
print(f"[build_pyd] WARN: 远程目录不可达: {REMOTE_DEPLOY_DIR},跳过部署",
|
||
file=sys.stderr)
|
||
return
|
||
dst = REMOTE_DEPLOY_DIR / src.name
|
||
shutil.copy2(src, dst)
|
||
print(f"[build_pyd] 步骤 4/4: 已部署 {src.name} -> {dst}")
|
||
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(description="编译 mil + 伴生包为 .pyd,并打包成 zip")
|
||
parser.add_argument("--clean", action="store_true", help="清理 build/ 与上次产物")
|
||
parser.add_argument("--no-progress", action="store_true", help="关闭进度条(CI 友好)")
|
||
parser.add_argument("--no-deploy", action="store_true", help="不复制 zip 到网络盘")
|
||
parser.add_argument(
|
||
"--only",
|
||
type=str,
|
||
default="",
|
||
help="只编指定包(逗号分隔),如 --only mil,openpyxl;默认全编",
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
if args.clean and BUILD_DIR.exists():
|
||
shutil.rmtree(BUILD_DIR)
|
||
|
||
targets = ["mil"]
|
||
if args.only:
|
||
targets = [p.strip() for p in args.only.split(",") if p.strip()]
|
||
else:
|
||
targets += COMPANIONS
|
||
|
||
print("[build_pyd] 步骤 1/4: Nuitka 编译 .pyd")
|
||
for pkg in targets:
|
||
print(f"[build_pyd] 正在编译: {pkg}")
|
||
out = build_one(pkg, args.no_progress)
|
||
print(f"[build_pyd] {pkg} -> {out}")
|
||
|
||
ok = _export_and_pack()
|
||
if ok and not args.no_deploy:
|
||
_deploy_zip()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main() |