119 lines
3.9 KiB
Python
119 lines
3.9 KiB
Python
"""宿主启动时的 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()
|