Files
mil_sdk/tools/export_runtime.py
T

284 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.
"""导出插件分发包到 plugins/ 目录。
把以下内容打包到 plugins/
1. build/ 下的 .pydmil + openpyxl + et_xmlfile
2. 从当前 Python 环境拷贝 stdlib 子包(.py 文件)
3. 从 Python 安装根目录拷贝 C 扩展 .pyd(如 _elementtree.pyd
4. 生成 manifest.json 供 runtime_hook.py 读取
产物 plugins/ 目录就是完整的"插件分发包"——宿主直接放到 exe 旁边即可。
用法:
# 前置:先编译 .pyd
python build_pyd.py --clean
# 导出插件分发包
python tools/export_runtime.py
# 产物:plugins/ 目录
"""
from __future__ import annotations
import json
import platform
import shutil
import sys
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
BUILD_DIR = ROOT / "build"
PLUGINS_DIR = ROOT / "plugins"
# 用 importlib 加载 manifest(绕过 mil/__init__.py 的 PySide6 依赖)
import importlib.util
_spec = importlib.util.spec_from_file_location(
"mil_manifest", ROOT / "mil" / "core" / "manifest.py"
)
_manifest = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_manifest)
def _python_tag() -> str:
"""返回 Python ABI tag,如 cp311-win_amd64。"""
impl = "cp" + str(sys.version_info.major) + str(sys.version_info.minor)
plat = "win_amd64" if platform.system() == "Windows" else platform.machine()
return f"{impl}-{plat}"
def _copy_pyd_files() -> list[str]:
"""把 build/ 下的 .pyd 拷到 plugins/,返回已拷贝的包名列表。"""
copied: list[str] = []
for pkg in _manifest.COMPANION_PACKAGES + ["mil"]:
matches = list(BUILD_DIR.glob(f"{pkg}.*.pyd"))
if not matches:
matches = list((BUILD_DIR / pkg).glob(f"{pkg}.*.pyd"))
if not matches:
print(f"[export] WARN: build/ 下未找到 {pkg}.*.pyd,跳过")
continue
dst = PLUGINS_DIR / matches[0].name
shutil.copy2(matches[0], dst)
copied.append(pkg)
print(f"[export] {pkg}: {matches[0].name} -> {dst}")
return copied
def _find_stdlib_zip() -> Path | None:
"""定位 Python stdlib 的 zip 文件(如 python310.zip)。"""
for p in sys.path:
path = Path(p)
if path.is_file() and path.suffix == ".zip" and "python" in path.name.lower():
return path
return None
def _extract_from_zip(zip_path: Path, pkg_name: str, dst: Path) -> bool:
"""从 zip 文件提取 stdlib 包目录到 plugins/。
Args:
zip_path: python310.zip 路径
pkg_name: 顶层包名(如 "xml"
dst: 目标目录 plugins/xml
Returns:
是否成功提取
"""
try:
with zipfile.ZipFile(zip_path, "r") as zf:
members = [m for m in zf.namelist() if m.startswith(f"{pkg_name}/")]
if not members:
return False
zf.extractall(PLUGINS_DIR, members=members)
print(f"[export] stdlib {pkg_name}: {zip_path} (zip) -> {dst}")
return True
except Exception as e:
print(f"[export] ERROR: 从 zip 提取 {pkg_name} 失败: {e}")
return False
def _copy_stdlib_from_fs(pkg_name: str, src: Path) -> bool:
"""从文件系统拷贝 stdlib 包目录到 plugins/。"""
dst = PLUGINS_DIR / pkg_name
if dst.exists():
shutil.rmtree(dst)
try:
shutil.copytree(src, dst, dirs_exist_ok=False)
print(f"[export] stdlib {pkg_name}: {src} (fs) -> {dst}")
return True
except Exception as e:
print(f"[export] ERROR: 拷贝 {pkg_name} 失败: {e}")
return False
def _copy_stdlib_packages() -> list[str]:
"""把 manifest 声明的 stdlib 子包从当前 Python 环境拷到 plugins/。
支持两种 stdlib 存储形态:
1. 文件系统目录(<python>/Lib/xml/)→ shutil.copytree
2. zip 压缩包(python310.zip/xml/)→ zipfile 提取
"""
copied: list[str] = []
seen: set[str] = set()
stdlib_zip = _find_stdlib_zip()
for module_name in _manifest.all_stdlib_modules():
top = module_name.split(".")[0]
if top in seen:
continue
seen.add(top)
dst = PLUGINS_DIR / top
if dst.exists():
shutil.rmtree(dst)
# 优先从文件系统找
try:
import importlib.util
spec = importlib.util.find_spec(top)
if spec and spec.submodule_search_locations:
src = Path(spec.submodule_search_locations[0])
if src.is_dir() and ".zip" not in str(src):
if _copy_stdlib_from_fs(top, src):
copied.append(top)
continue
except (ImportError, ValueError):
pass
# 从 zip 提取
if stdlib_zip:
if _extract_from_zip(stdlib_zip, top, dst):
copied.append(top)
continue
print(f"[export] WARN: 找不到 stdlib 包 {top}fs 和 zip 均无)")
return copied
def _copy_py_packages() -> list[str]:
"""把 COMPANION_PACKAGES_PY 声明的包从 site-packages 拷 .py 源码到 plugins/。
这些包不编译 .pyd,保留 .py 源码形式,import 走标准 importlib 机制,
能正确从 sys.path 找到插件自带的 stdlib xml。
"""
copied: list[str] = []
for pkg in _manifest.COMPANION_PACKAGES_PY:
try:
import importlib.util
spec = importlib.util.find_spec(pkg)
if spec is None or not spec.submodule_search_locations:
print(f"[export] WARN: 找不到包 {pkg} 的源码目录")
continue
src = Path(spec.submodule_search_locations[0])
if not src.is_dir():
print(f"[export] WARN: {pkg} 源码路径不是目录: {src}")
continue
dst = PLUGINS_DIR / pkg
# 排除 __pycache__,只拷 .py 文件
shutil.copytree(src, dst,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
copied.append(pkg)
print(f"[export] {pkg}: {src} (.py) -> {dst}")
except Exception as e:
print(f"[export] ERROR: 拷贝 {pkg} 源码失败: {e}")
return copied
def _copy_c_extensions() -> list[str]:
"""从 Python 安装目录拷贝 C 扩展 .pyd 到 plugins/。
C 扩展(如 _elementtree.pyd)是顶层模块,不在 stdlib 包目录里。
Windows 下 .pyd 可能在 py_root/ 或 py_root/DLLs/,都查一遍。
"""
copied: list[str] = []
py_root = Path(sys.base_prefix)
search_dirs = [py_root, py_root / "DLLs"]
for stdlib_pkg, ext_names in _manifest.COMPANION_C_EXTENSIONS.items():
for ext_name in ext_names:
candidates: list[Path] = []
for d in search_dirs:
candidates = list(d.glob(f"{ext_name}.*.pyd"))
if candidates:
break
candidates = list(d.glob(f"{ext_name}*.pyd"))
if candidates:
break
if not candidates:
print(f"[export] WARN: 找不到 C 扩展 {ext_name}.pyd"
f"(已查 {', '.join(str(d) for d in search_dirs)}")
continue
dst = PLUGINS_DIR / candidates[0].name
shutil.copy2(candidates[0], dst)
copied.append(ext_name)
print(f"[export] C 扩展 {ext_name}: {candidates[0]} -> {dst}")
return copied
def _read_plugin_meta() -> tuple[str, str, str]:
"""从 mil/__init__.py 源码提取插件名/版本/描述(绕过 PySide6 import)。
Returns:
(plugin_name, plugin_version, plugin_description)
"""
import re
src = (ROOT / "mil" / "__init__.py").read_text(encoding="utf-8")
name = re.search(r'NMAE\s*=\s*"([^"]+)"', src).group(1)
major = re.search(r"__MAJOR_VER:\s*int\s*=\s*(\d+)", src).group(1)
minor = re.search(r"__MINOR_VER:\s*int\s*=\s*(\d+)", src).group(1)
micro = re.search(r"__MICRO_VER:\s*int\s*=\s*(\d+)", src).group(1)
desc_match = re.search(r'DESCRIPITION\s*=\s*"""([\s\S]*?)"""', src)
desc = desc_match.group(1).strip() if desc_match else ""
return name, f"{major}.{minor}.{micro}", desc
def _write_manifest(copied_packages: list[str], stdlib_packages: list[str],
c_extensions: list[str]) -> None:
"""生成 plugins/manifest.json,含插件元数据供远程 zip 扫描读取。"""
name, version, desc = _read_plugin_meta()
manifest = {
"python_tag": _python_tag(),
"python_version": platform.python_version(),
"packages": copied_packages,
"stdlib": stdlib_packages,
"c_extensions": c_extensions,
"plugin_name": name,
"plugin_version": version,
"plugin_description": desc,
# 主业务包名固定为 mil,用作 zip 文件名前缀与本地子目录名
"tool_name": "mil",
}
dst = PLUGINS_DIR / "manifest.json"
dst.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"[export] manifest -> {dst}")
def main() -> None:
if not BUILD_DIR.exists():
print("[export] ERROR: build/ 不存在,请先运行 python build_pyd.py", file=sys.stderr)
sys.exit(1)
if PLUGINS_DIR.exists():
shutil.rmtree(PLUGINS_DIR)
PLUGINS_DIR.mkdir(parents=True)
copied_pyd = _copy_pyd_files()
# openpyxl/et_xmlfile/xml.etree 已编进 mil.pyd,不需要单独拷。
# 只需拷 C 扩展(_elementtree.pyd, pyexpat.pyd),运行时走标准 import 加载。
copied_cext = _copy_c_extensions()
_write_manifest(copied_pyd, [], copied_cext)
print(f"\n[export] 完成!plugins/ 目录已就绪:{PLUGINS_DIR}")
print(f"[export] .pyd 文件: {len(copied_pyd)} 个(含已编入的 openpyxl/et_xmlfile/xml.etree")
print(f"[export] C 扩展: {len(copied_cext)} 个(运行时从插件目录加载)")
print(f"[export] Python 版本: {platform.python_version()}")
print(f"[export] 下一步: 把 plugins/ 目录放到宿主 exe 旁边")
if __name__ == "__main__":
main()