"""宿主启动时的 runtime hook。 宿主打包时通过 --runtime-hook 指定本文件,宿主 exe 启动时会在 main 之前执行本脚本。 作用:把 exe 旁边的 plugins/ 目录加进 sys.path, 让插件自带的 .pyd + stdlib 子包(含 C 扩展)可以被 Python import 机制找到。 宿主打包命令示例(PyInstaller): pyinstaller --onefile --runtime-hook tools/runtime_hook.py host_main.py 宿主打包命令示例(Nuitka): nuitka --standalone --onefile --include-module=runtime_hook host_main.py 宿主部署目录布局: host.exe plugins/ ← 由 mil_sdk/tools/export_runtime.py 生成 └── mil/ ← 每插件一个子目录(zip 解压产物) ├── mil.cp311-*.pyd ├── openpyxl.cp311-*.pyd ├── et_xmlfile.cp311-*.pyd ├── xml/ ← 插件自带的 stdlib 子包(含 _elementtree.pyd) │ └── etree/ └── manifest.json 兼容旧扁平布局:plugins/ 直接含 manifest.json(过渡期)。 注意:本文件不依赖任何第三方包,只用 stdlib(os / sys / json / pathlib)。 因为它在宿主 sys.path 配置好之前执行,不能 import 任何外部包。 """ from __future__ import annotations import os import sys from pathlib import Path def _resolve_plugins_dir() -> Path | None: """定位 plugins/ 目录。 优先级: 1. 环境变量 MIL_PLUGIN_DIR 2. sys.executable 旁边的 plugins/(onedir 模式) 3. sys._MEIPASS 旁边的 plugins/(onefile 模式解压目录) """ # 1) 环境变量 env = os.environ.get("MIL_PLUGIN_DIR") if env: p = Path(env) if p.is_dir(): return p.resolve() # 2) onedir:exe 旁边的 plugins/ exe_dir = Path(sys.executable).resolve().parent plugins = exe_dir / "plugins" if plugins.is_dir(): return plugins.resolve() # 3) onefile:PyInstaller 解压目录旁边的 plugins/ meipass = getattr(sys, "_MEIPASS", None) if meipass: plugins = Path(meipass).parent / "plugins" if plugins.is_dir(): return plugins.resolve() return None def _check_python_version(manifest_path: Path) -> None: """校验插件的 Python 版本与当前解释器兼容(minor 版本必须一致)。""" import json try: manifest = json.loads(manifest_path.read_text(encoding="utf-8")) except Exception: return # manifest 损坏不阻止启动,让 import 错误自己暴露 plugin_py = manifest.get("python_version", "") host_py = f"{sys.version_info.major}.{sys.version_info.minor}" if not plugin_py.startswith(host_py): sys.stderr.write( f"[mil] WARNING: 插件 Python 版本({plugin_py}) 与宿主({host_py}) 可能不兼容\n" ) def _insert_path(path: Path) -> None: """把 path 加到 sys.path[0],已存在则跳过。""" p = str(path) if p not in sys.path: sys.path.insert(0, p) def setup() -> None: """把 plugins/ 或 plugins/*/ 子目录加进 sys.path 最前面。 兼容两种布局: - 旧扁平:plugins/ 直接含 manifest.json(过渡期) - 新子目录:plugins/<插件名>/ 各自含 manifest.json """ plugins_dir = _resolve_plugins_dir() if plugins_dir is None: return # 旧扁平布局:plugins/ 直接有 manifest.json if (plugins_dir / "manifest.json").exists(): _check_python_version(plugins_dir / "manifest.json") _insert_path(plugins_dir) return # 新子目录布局:逐个扫描 plugins/*/ for sub in plugins_dir.iterdir(): if not sub.is_dir(): continue if (sub / "manifest.json").exists(): _check_python_version(sub / "manifest.json") _insert_path(sub) # 模块加载时立即执行(PyInstaller runtime hook 的约定) setup()