"""导出插件分发包到 plugins/ 目录。 把以下内容打包到 plugins/: 1. build/ 下的 .pyd(mil + 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. 文件系统目录(/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()