Files
mil_sdk/build_pyd.py
T

248 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""通过 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 都是纯 PythonNuitka --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()