feat: 增加伴生包加载器、manifest配置,Excel处理优化

This commit is contained in:
2026-07-21 16:26:16 +08:00
parent 45e4f1ecf2
commit 91bb61444e
19 changed files with 1364 additions and 82 deletions
+1 -1
View File
@@ -175,4 +175,4 @@ cython_debug/
.pypirc
*.xlsx
*.md
/plugins
+248
View File
@@ -0,0 +1,248 @@
"""通过 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()
+11 -2
View File
@@ -3,6 +3,15 @@
"FilePath": "",
"AddTimeEn": true,
"GeratePath": true,
"CurrProject": "",
"ItemConfigs": {}
"CurrProject": "ABC",
"ItemConfigs": {
"吉利E211": {
"DataPath": "C:/Users/xff1atk/Downloads/模型检查单.xlsx",
"FilePath": "C:/Users/xff1atk/Downloads/Geely_E22H_Signallist.xlsx"
},
"ABC": {
"DataPath": "C:/Users/xff1atk/Downloads/Geely_E22H_Signallist.xlsx",
"FilePath": "C:/Users/xff1atk/Downloads/合肥模型团队-MIL测试模版V0.3.xlsx"
}
}
}
-27
View File
@@ -1,27 +0,0 @@
# This file was generated by Nuitka
# Stubs included by default
from openpyxl import Workbook
from ui.mil_tool import FrameMILTool
def create_mil_tool(parent: typing.Any) -> typing.Any:
...
__name__ = ...
# Modules used internally, to allow implicit dependencies to be seen:
import os
import openpyxl
import logging
import dataclasses
import typing
import copy
import openpyxl.worksheet
import openpyxl.worksheet.worksheet
import _frozen_importlib_external
import PySide6
import PySide6.QtWidgets
import PySide6.QtCore
import PySide6.QtGui
+51 -2
View File
@@ -1,4 +1,53 @@
import re
from .ui.mil_tool import FrameMILTool
from .core.companion_loader import load_companions
from .core.manifest import COMPANION_PACKAGES
def create_mil_tool(parent=None):
return FrameMILTool(parent)
# 工具名称
NMAE = "MIL Tool"
# 工具版本
__MAJOR_VER: int = 0 # 主版本号
__MINOR_VER: int = 0 # 次版本号
__MICRO_VER: int = 4 # 修订版本号
VERSION:str = f"{__MAJOR_VER}.{__MINOR_VER}.{__MICRO_VER}"
# 工具描述
DESCRIPITION = """
主要用于生成Simlink Test Case。
"""
SVG = """
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 512 512"><path fill="#1de9b6" d="M495.855 367.604c-28.98-73.177-53.037-148.231-80.725-221.893c-12.23-31.362-24.198-62.986-40.868-92.33c-6.62-10.535-12.956-22.119-23.814-28.86c-2.739-1.74-5.828-2.479-8.88-2.42c-5.084.096-10.14 2.37-14.025 5.65c-14.571 11.543-23.209 28.334-32.896 43.793c-17.114 28.698-35.156 58.748-63.773 77.396c-13.44 9.485-31.039 10.514-43.995 20.686c-17.72 13.36-29.767 32.31-44.298 48.737c-3.31 3.956-8.436 5.53-12.916 7.67C86.315 243.71 42.945 261.35 0 279.916c36.367 28.132 75.115 53.157 112.208 80.321c10.172-2.018 20.383-6.196 30.877-4.339c16.63 5.207 26.377 21.15 34.006 35.721c15.5 31.765 26.7 65.307 39.253 98.283c20.988-1.493 39.878-12.31 56.104-25.025c31.24-25.307 55.034-58.001 81.23-88.192c12.552-13.199 25.347-28.92 44.197-32.593c19.05-4.601 39.454 2.22 53.581 15.338c19.818 17.719 37.376 38.203 60.544 51.765c-3.108-15.338-10.575-29.101-16.145-43.592M171.844 316.04c-18.284 10.817-37.699 19.717-56.71 29.162c-29.828-19.98-58.889-41.15-88.192-61.856c39.515-17.315 79.635-33.4 119.675-49.545c18.97 14.813 38.547 28.88 57.517 43.693c-8.92 14.248-19.657 27.487-32.29 38.546m40.665-49.646c-18.85-14.167-37.94-27.951-56.508-42.482c11.907-16.226 24.945-31.906 40.565-44.701c10.333-6.62 22.966-8.456 33.703-14.43c25.469-12.714 42.845-36.044 59.434-58.324c-21.271 55.216-43.975 110.654-77.194 159.937"/></svg>
"""
# 伴生包清单从 manifest 导入(唯一事实源),此处保留引用以兼容旧调用方
# 加新伴生包:改 mil/core/manifest.py 的 COMPANION_PACKAGES,不要改这里
def create_plugin(workspace, parent=None):
# 先挂伴生包,再构造 UI——mil 包内任何 import openpyxl 都将命中 build/ 下的 pyd
load_companions(COMPANION_PACKAGES)
return FrameMILTool(workspace, parent)
def create_plugin_svg(file:str):
with open(file,'w', encoding='utf-8') as f:
f.write(SVG.strip())
f.close()
def read_plugin_name():
return NMAE
def read_plugin_version():
return VERSION
def read_plugin_description():
return re.sub(r'^[\r\n]+',"",DESCRIPITION)
__all__ = [
"create_plugin",
"create_plugin_svg",
"read_plugin_name",
"read_plugin_version",
"read_plugin_description",
]
+11 -14
View File
@@ -9,7 +9,7 @@ from typing import Any
logger = logging.getLogger(__name__)
@dataclass
class Config:
class Config():
"""MIL SDK 全局配置
承载测试工程相关的全局开关与路径信息,是 UI 层与 core 层之间的
@@ -23,6 +23,7 @@ class Config:
CurrProject: 当前工程名称
ItemConfigs: 各模块/用例项的细粒度配置
"""
path:str
DataPath:str = ""
FilePath:str = ""
AddTimeEn:bool = True
@@ -31,6 +32,8 @@ class Config:
# dict 是可变类型,必须用 default_factory 显式实例化,避免多个 Config 共享同一对象
ItemConfigs: dict = field(default_factory=dict)
def check_path(self):
return os.path.exists(self.path)
def to_dict(self):
"""将 Config 实例序列化为 dict,便于写入 JSON 文件。"""
@@ -40,10 +43,10 @@ class Config:
"AddTimeEn":self.AddTimeEn,
"GeratePath":self.GeratePath,
"CurrProject":self.CurrProject,
"ItemConfigs":self.ItemConfigs
"ItemConfigs":self.ItemConfigs,
}
def load_config(self, config_path: str) -> "Config":
def load_config(self) -> "Config":
"""从 JSON 文件读取配置并填充到当前实例的各个字段。
解析规则:
@@ -63,11 +66,11 @@ class Config:
"""
try:
# 以 UTF-8 读取 JSON,避免 Windows 默认编码带来的乱码问题
with open(config_path, 'r', encoding='utf-8') as f:
with open(self.path, 'r', encoding='utf-8') as f:
raw = json.load(f)
except FileNotFoundError:
# 显式记录并重抛,遵循"错误必须显式处理,禁止静默失败"
logger.error(f"配置文件{config_path}未找到")
logger.error(f"配置文件{self.path}未找到")
raise
if not isinstance(raw, dict):
@@ -81,7 +84,7 @@ class Config:
setattr(self, key, value)
return self
def save_config(self,config_path:str):
def save_config(self):
"""将当前配置以 JSON 格式写入磁盘。
Args:
@@ -91,15 +94,10 @@ class Config:
Exception: 写入失败时记录日志并原样抛出异常。
"""
try:
with open(config_path,'w',encoding='utf-8') as f:
with open(self.path,'w',encoding='utf-8') as f:
json.dump(self.to_dict(), f, indent=4, ensure_ascii=False)
except Exception as e:
logger.error(f"配置文件{config_path}写入失败{e.args}")
logger.error(f"配置文件{self.path}写入失败{e.args}")
@dataclass
class DataLog:
@@ -111,7 +109,6 @@ class DataLog:
"""
time: float = 0.0
value: str = ""
@dataclass
class SignalData:
+106
View File
@@ -0,0 +1,106 @@
"""伴生包加载器:把纯 Python 伴生包从产物目录挂到 sys.modules。
设计目标:宿主 exe 一次打包后,mil 的伴生依赖(openpyxl / et_xmlfile / ...
可通过"覆盖 build/<pkg>/ 下的 .pyd 或 mil_runtime/<pkg>/ 下的源码"增量更新,
宿主不需要重打。
加载顺序(命中即停):
1. sys.modules 已存在(不动)
2. mil_runtime/<pkg>/ 下的纯 Python 源码(数据化部署)
3. build/<pkg>/ 下的预编译 .pyd(本轮主路径)
4. 都不命中 → 跳过并 warning,由调用方决定是否兜底
约定:
- mil 包的父目录下存在两个候选目录:mil_runtime/(源码)和 build/.pyd)。
- 解析顺序可通过环境变量 MIL_RUNTIME_DIR / MIL_BUILD_DIR 覆盖。
"""
from __future__ import annotations
import importlib.util
import logging
import os
import sys
from pathlib import Path
from types import ModuleType
logger = logging.getLogger(__name__)
DEFAULT_RUNTIME_DIRNAME = "mil_runtime"
DEFAULT_BUILD_DIRNAME = "build"
def _mil_root() -> Path:
"""获取 mil 包的父目录(即 mil_runtime/ 与 build/ 所在的工程根)。
不通过 `import mil` 解析位置,避免触发 mil 包完整初始化
(宿主侧必装 PySide6,开发侧不一定有)。
"""
# 本文件位于 <root>/mil/core/companion_loader.py,往上两级就是 <root>
return Path(__file__).resolve().parent.parent.parent
def _load_from_source(runtime_dir: Path, pkg: str) -> ModuleType | None:
"""尝试从 mil_runtime/<pkg>/ 加载纯 Python 源码包。"""
pkg_dir = runtime_dir / pkg
if not (pkg_dir / "__init__.py").is_file():
return None
spec = importlib.util.spec_from_file_location(
pkg,
pkg_dir / "__init__.py",
submodule_search_locations=[str(pkg_dir)],
)
if spec is None or spec.loader is None:
logger.warning(f"无法为 {pkg} 构造 specruntime 源码),跳过")
return None
module = importlib.util.module_from_spec(spec)
sys.modules[pkg] = module
spec.loader.exec_module(module)
return module
def _load_from_pyd(build_dir: Path, pkg: str) -> ModuleType | None:
"""尝试从 build/<pkg>/<pkg>.cp311-*.pyd 加载预编译包。"""
candidates = list(build_dir.glob(f"{pkg}.*.pyd"))
if not candidates:
return None
pyd_path = candidates[0]
spec = importlib.util.spec_from_file_location(pkg, pyd_path)
if spec is None or spec.loader is None:
logger.warning(f"无法为 {pkg} 构造 specpyd={pyd_path}),跳过")
return None
module = importlib.util.module_from_spec(spec)
sys.modules[pkg] = module
spec.loader.exec_module(module)
return module
def load_companions(packages: list[str]) -> list[str]:
"""按上述顺序把每个伴生包挂到 sys.modules,返回成功挂载的包名列表。"""
root = _mil_root()
runtime_dir = Path(os.environ.get("MIL_RUNTIME_DIR", root / DEFAULT_RUNTIME_DIRNAME))
build_dir = Path(os.environ.get("MIL_BUILD_DIR", root / DEFAULT_BUILD_DIRNAME))
loaded: list[str] = []
for pkg in packages:
if pkg in sys.modules:
logger.debug(f"伴生包 {pkg} 已在 sys.modules,跳过")
loaded.append(pkg)
continue
mod = (
_load_from_source(runtime_dir, pkg)
or _load_from_pyd(build_dir, pkg)
)
if mod is not None:
loaded.append(pkg)
logger.info(f"伴生包已挂载: {pkg}")
else:
logger.warning(
f"伴生包未找到: {pkg}runtime={runtime_dir} / build={build_dir} 均无)"
)
return loaded
+41
View File
@@ -0,0 +1,41 @@
"""插件运行期依赖清单。
这是唯一的事实源:
- build_pyd.py 读 COMPANION_PACKAGES_PYD 决定要编哪些伴生包;
- build_pyd.py 编译 mil 时用 --include-package 把 COMPANION_PACKAGES_INLINE 编进 mil.pyd
- tools/export_runtime.py 读 COMPANION_C_EXTENSIONS 决定要拷哪些 C 扩展。
加新伴生包时只动这里,其它脚本自动跟随。
"""
from __future__ import annotations
# 需 Nuitka 编译成独立 .pyd 的伴生包(业务代码需保护)
COMPANION_PACKAGES_PYD: list[str] = []
# 编译 mil 时用 --include-package 编进 mil.pyd 的纯 Python 包
# 这些包的 .py 代码编进 mil.pyd,运行时内部 hard-import,不查 sys.path
COMPANION_PACKAGES_INLINE: list[str] = [
"openpyxl",
"et_xmlfile",
]
# 编译 mil 时用 --include-module 编进 mil.pyd 的 stdlib 子模块
# Nuitka 默认不编译 stdlib--nofollow-stdlib),所以必须显式 include。
# 这些是 openpyxl/et_xmlfile 间接依赖的纯 Python stdlib,编进 mil.pyd 后
# 运行时 hard-import,不查 sys.path。只有 C 扩展(_elementtree)留给运行时。
COMPANION_STDLIB_INLINE: list[str] = [
"xml.etree",
"xml.etree.ElementTree",
"xml.etree.ElementPath",
"xml.etree.ElementInclude",
]
# 兼容旧接口:所有伴生包
COMPANION_PACKAGES: list[str] = COMPANION_PACKAGES_PYD + COMPANION_PACKAGES_INLINE
# 插件依赖的 C 扩展模块(Nuitka --module 编不进去,运行时从插件目录加载)
# key = 所属 stdlib 包名, value = C 扩展模块名列表
COMPANION_C_EXTENSIONS: dict[str, list[str]] = {
"xml": ["_elementtree"], # xml.etree.ElementTree 内部 from _elementtree import *
}
+122
View File
@@ -0,0 +1,122 @@
import os
import logging
from PySide6.QtGui import *
from PySide6.QtCore import *
from PySide6.QtWidgets import *
logger = logging.getLogger(__name__)
from .mil_project_ui import Ui_FrameMILProject
from mil.core import Config
class MILProject(QFrame, Ui_FrameMILProject):
update_item_name_signal = Signal()
update_item_data_path_signal = Signal(str,str)
update_item_file_path_signal = Signal(str,str)
def __init__(self, config, name = None,parent = None):
super().__init__(parent)
self.setupUi(self)
self.initUI(config,name)
def initUI(self,config:Config, name:str):
self.lineEditDataPath.setReadOnly(True)
self.lineEditFilePath.setReadOnly(True)
self.name = name
self.config = config
self.ItemConfigs = config.ItemConfigs
if self.name is not None and self.name in self.ItemConfigs.keys():
self.lineEditName.setText(name)
self.lineEditDataPath.setText(self.ItemConfigs[self.name]["DataPath"])
self.lineEditFilePath.setText(self.ItemConfigs[self.name]["FilePath"])
else:
self.pushButtonDataPath.setEnabled(False)
self.pushButtonFilePath.setEnabled(False)
self.lineEditName.setPlaceholderText("必填项!!!")
self.lineEditDataPath.setPlaceholderText("点击【选择数据文件】按钮,选择路径!")
self.lineEditFilePath.setPlaceholderText("点击【选择测试用例】按钮,选择路径!")
self.lineEditName.editingFinished.connect(self.on_name_editing_finished_event)
self.lineEditDataPath.editingFinished.connect(self.on_datapath_editing_finished_event)
self.lineEditFilePath.editingFinished.connect(self.on_filepath_editing_finished_event)
self.pushButtonDataPath.clicked.connect(self.on_data_path_event)
self.pushButtonFilePath.clicked.connect(self.on_file_path_event)
def on_name_editing_finished_event(self):
name = self.lineEditName.text()
if name.replace(" ", "") == "":
self.lineEditName.setText(self.name)
logger.warning("项目名称是必填项!!!")
return
if name == self.name:
return
if self.name is None:
self.name = name
self.ItemConfigs[name] = dict()
self.ItemConfigs[name]["DataPath"] = ""
self.ItemConfigs[name]["FilePath"] = ""
else:
self.ItemConfigs[name] = self.ItemConfigs.pop(self.name)
if self.name == self.config.CurrProject:
self.config.CurrProject = name
self.name = name
self.config.save_config()
self.update_item_name_signal.emit()
self.pushButtonDataPath.setEnabled(True)
self.pushButtonFilePath.setEnabled(True)
def on_datapath_editing_finished_event(self):
name = self.lineEditName.text()
data_path = self.lineEditDataPath.text()
if data_path.replace(" ","") == "":
logger.warning("数据文件路径无效!")
return
self.ItemConfigs[name]["DataPath"] = data_path
self.config.save_config()
self.update_item_data_path_signal.emit(name,data_path)
def on_filepath_editing_finished_event(self):
name = self.lineEditName.text()
file_path = self.lineEditFilePath.text()
if file_path.replace(" ","") == "":
logger.warning("测试用例路径无效!")
return
self.ItemConfigs[name]["FilePath"] = file_path
self.config.save_config()
self.update_item_file_path_signal.emit(name,file_path)
def on_data_path_event(self):
dataPath = self.lineEditDataPath.text()
if os.path.exists(dataPath):
path,type = QFileDialog.getOpenFileName(self, "选择文件", dataPath,"Excel工作簿(*.xlsx)")
else:
path,type = QFileDialog.getOpenFileName(self, "选择文件", "./","Excel工作簿(*.xlsx)")
if not os.path.exists(path):
return
self.lineEditDataPath.setText(path)
self.on_datapath_editing_finished_event()
def on_file_path_event(self):
filePath = self.lineEditFilePath.text()
if os.path.exists(filePath):
path,type = QFileDialog.getOpenFileName(self, "选择文件", filePath,"Excel工作簿(*.xlsx)")
else:
path,type = QFileDialog.getOpenFileName(self, "选择文件", "./","Excel工作簿(*.xlsx)")
if not os.path.exists(path):
return
self.lineEditFilePath.setText(path)
self.on_filepath_editing_finished_event()
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>FrameProject</class>
<widget class="QFrame" name="FrameProject">
<class>FrameMILProject</class>
<widget class="QFrame" name="FrameMILProject">
<property name="geometry">
<rect>
<x>0</x>
+23 -23
View File
@@ -18,26 +18,26 @@ from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
from PySide6.QtWidgets import (QApplication, QFrame, QGridLayout, QLabel,
QLineEdit, QPushButton, QSizePolicy, QWidget)
class Ui_FrameProject(object):
def setupUi(self, FrameProject):
if not FrameProject.objectName():
FrameProject.setObjectName(u"FrameProject")
FrameProject.resize(740, 95)
self.gridLayout_4 = QGridLayout(FrameProject)
class Ui_FrameMILProject(object):
def setupUi(self, FrameMILProject):
if not FrameMILProject.objectName():
FrameMILProject.setObjectName(u"FrameMILProject")
FrameMILProject.resize(740, 95)
self.gridLayout_4 = QGridLayout(FrameMILProject)
self.gridLayout_4.setObjectName(u"gridLayout_4")
self.gridLayout = QGridLayout()
self.gridLayout.setObjectName(u"gridLayout")
self.label = QLabel(FrameProject)
self.label = QLabel(FrameMILProject)
self.label.setObjectName(u"label")
self.gridLayout.addWidget(self.label, 0, 0, 1, 1)
self.label_2 = QLabel(FrameProject)
self.label_2 = QLabel(FrameMILProject)
self.label_2.setObjectName(u"label_2")
self.gridLayout.addWidget(self.label_2, 1, 0, 1, 1)
self.label_3 = QLabel(FrameProject)
self.label_3 = QLabel(FrameMILProject)
self.label_3.setObjectName(u"label_3")
self.gridLayout.addWidget(self.label_3, 2, 0, 1, 1)
@@ -47,7 +47,7 @@ class Ui_FrameProject(object):
self.gridLayout_2 = QGridLayout()
self.gridLayout_2.setObjectName(u"gridLayout_2")
self.lineEditName = QLineEdit(FrameProject)
self.lineEditName = QLineEdit(FrameMILProject)
self.lineEditName.setObjectName(u"lineEditName")
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
@@ -57,14 +57,14 @@ class Ui_FrameProject(object):
self.gridLayout_2.addWidget(self.lineEditName, 0, 0, 1, 1)
self.lineEditDataPath = QLineEdit(FrameProject)
self.lineEditDataPath = QLineEdit(FrameMILProject)
self.lineEditDataPath.setObjectName(u"lineEditDataPath")
sizePolicy.setHeightForWidth(self.lineEditDataPath.sizePolicy().hasHeightForWidth())
self.lineEditDataPath.setSizePolicy(sizePolicy)
self.gridLayout_2.addWidget(self.lineEditDataPath, 1, 0, 1, 1)
self.lineEditFilePath = QLineEdit(FrameProject)
self.lineEditFilePath = QLineEdit(FrameMILProject)
self.lineEditFilePath.setObjectName(u"lineEditFilePath")
sizePolicy.setHeightForWidth(self.lineEditFilePath.sizePolicy().hasHeightForWidth())
self.lineEditFilePath.setSizePolicy(sizePolicy)
@@ -76,7 +76,7 @@ class Ui_FrameProject(object):
self.gridLayout_3 = QGridLayout()
self.gridLayout_3.setObjectName(u"gridLayout_3")
self.pushButtonDataPath = QPushButton(FrameProject)
self.pushButtonDataPath = QPushButton(FrameMILProject)
self.pushButtonDataPath.setObjectName(u"pushButtonDataPath")
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
sizePolicy1.setHorizontalStretch(0)
@@ -86,7 +86,7 @@ class Ui_FrameProject(object):
self.gridLayout_3.addWidget(self.pushButtonDataPath, 0, 0, 1, 1)
self.pushButtonFilePath = QPushButton(FrameProject)
self.pushButtonFilePath = QPushButton(FrameMILProject)
self.pushButtonFilePath.setObjectName(u"pushButtonFilePath")
sizePolicy1.setHeightForWidth(self.pushButtonFilePath.sizePolicy().hasHeightForWidth())
self.pushButtonFilePath.setSizePolicy(sizePolicy1)
@@ -97,18 +97,18 @@ class Ui_FrameProject(object):
self.gridLayout_4.addLayout(self.gridLayout_3, 0, 2, 1, 1)
self.retranslateUi(FrameProject)
self.retranslateUi(FrameMILProject)
QMetaObject.connectSlotsByName(FrameProject)
QMetaObject.connectSlotsByName(FrameMILProject)
# setupUi
def retranslateUi(self, FrameProject):
FrameProject.setWindowTitle(QCoreApplication.translate("FrameProject", u"Frame", None))
self.label.setText(QCoreApplication.translate("FrameProject", u"\u6a21\u578b\u9879\u76ee\uff1a", None))
self.label_2.setText(QCoreApplication.translate("FrameProject", u"\u6570\u636e\u6587\u4ef6\uff1a", None))
self.label_3.setText(QCoreApplication.translate("FrameProject", u"\u6d4b\u8bd5\u7528\u4f8b\uff1a", None))
def retranslateUi(self, FrameMILProject):
FrameMILProject.setWindowTitle(QCoreApplication.translate("FrameMILProject", u"Frame", None))
self.label.setText(QCoreApplication.translate("FrameMILProject", u"\u6a21\u578b\u9879\u76ee\uff1a", None))
self.label_2.setText(QCoreApplication.translate("FrameMILProject", u"\u6570\u636e\u6587\u4ef6\uff1a", None))
self.label_3.setText(QCoreApplication.translate("FrameMILProject", u"\u6d4b\u8bd5\u7528\u4f8b\uff1a", None))
self.lineEditName.setText("")
self.pushButtonDataPath.setText(QCoreApplication.translate("FrameProject", u"\u9009\u62e9\u6570\u636e\u6587\u4ef6", None))
self.pushButtonFilePath.setText(QCoreApplication.translate("FrameProject", u"\u9009\u62e9\u6d4b\u8bd5\u7528\u4f8b", None))
self.pushButtonDataPath.setText(QCoreApplication.translate("FrameMILProject", u"\u9009\u62e9\u6570\u636e\u6587\u4ef6", None))
self.pushButtonFilePath.setText(QCoreApplication.translate("FrameMILProject", u"\u9009\u62e9\u6d4b\u8bd5\u7528\u4f8b", None))
# retranslateUi
+174 -8
View File
@@ -1,8 +1,13 @@
import os
import logging
from pathlib import Path
from .mil_tool_ui import Ui_FrameMILTool
from PySide6.QtWidgets import QFrame
from PySide6.QtGui import *
from PySide6.QtCore import *
from PySide6.QtWidgets import *
from .mil_project import MILProject
from mil.core import Config
@@ -11,16 +16,177 @@ logger = logging.getLogger(__name__)
CONF = "mil.json"
class FrameMILTool(QFrame,Ui_FrameMILTool):
def __init__(self,parent=None):
def __init__(self,workspace:Path, parent=None):
super().__init__(parent)
self.setupUi(self)
self.initUI()
self.initUI(workspace)
def initUI(self):
self.config = Config()
if os.path.exists(CONF):
self.config.load_config(CONF)
def initUI(self, workspace:Path):
self.workspace = workspace
if not self.workspace.exists():
os.mkdir(self.workspace)
self.conf = self.workspace.joinpath(CONF)
self.config = Config(self.conf)
if self.config.check_path():
self.config.load_config()
self.checkBox.setChecked(self.config.AddTimeEn)
if self.config.GeratePath:
self.radioButtonData.setChecked(True)
else:
self.radioButtonFile.setChecked(True)
else:
self.config.save_config(CONF)
self.config.save_config()
self.update_config()
for key in self.config.ItemConfigs.keys():
self.add_pro_event(key)
self.menu = QMenu(self.listWidget)
self.new_pro_acction = QAction("新建项目", self)
self.new_pro_acction.triggered.connect(self.on_new_pro_event)
self.del_pro_acction = QAction("删除项目", self)
self.del_pro_acction.triggered.connect(self.on_del_pro_event)
self.menu.addAction(self.new_pro_acction)
self.menu.addAction(self.del_pro_acction)
self.DataPath.setReadOnly(True)
self.FilePath.setReadOnly(True)
self.DataButton.clicked.connect(self.on_load_data_path)
self.FileButton.clicked.connect(self.on_load_file_path)
self.checkBox.toggled.connect(self.on_select_add_time_en)
self.radioButtonData.toggled.connect(self.on_select_gerate_path)
self.comboBox.currentTextChanged.connect(self.on_text_changed_event)
self.listWidget.customContextMenuRequested.connect(self.on_ContextMenuRequested)
def update_config(self):
self.comboBox.clear()
if self.config.ItemConfigs.keys().__len__() == 0:
return
self.lock_event = True
for key in self.config.ItemConfigs.keys():
self.comboBox.addItem(key)
self.lock_event = False
if self.config.CurrProject == "":
self.config.CurrProject = self.comboBox.currentText()
self.config.save_config()
self.DataPath.setText(self.config.ItemConfigs[self.comboBox.currentText()]["DataPath"])
self.FilePath.setText(self.config.ItemConfigs[self.comboBox.currentText()]["FilePath"])
else:
self.comboBox.setCurrentText(self.config.CurrProject)
self.DataPath.setText(self.config.ItemConfigs[self.config.CurrProject]["DataPath"])
self.FilePath.setText(self.config.ItemConfigs[self.config.CurrProject]["FilePath"])
def on_update_data_path_event(self,name,data_path):
if name == self.comboBox.currentText():
self.DataPath.setText(data_path)
def on_update_file_path_event(self,name,file_path):
if name == self.comboBox.currentText():
self.FilePath.setText(file_path)
def on_select_gerate_path(self,checked):
self.config.GeratePath = checked
self.config.save_config()
def on_select_add_time_en(self,checked):
self.config.AddTimeEn = checked
self.config.save_config()
def on_ContextMenuRequested(self,point:QPoint):
self.menu.exec_(QCursor.pos())
def on_text_changed_event(self,text:str):
if text == "" or self.lock_event:
return
self.config.CurrProject= text
self.config.save_config()
self.DataPath.setText(self.config.ItemConfigs[self.config.CurrProject]["DataPath"])
self.FilePath.setText(self.config.ItemConfigs[self.config.CurrProject]["FilePath"])
pass
def on_new_pro_event(self):
item = QListWidgetItem()
mil_project = MILProject(self.config)
mil_project.update_item_name_signal.connect(self.update_config)
mil_project.update_item_data_path_signal.connect(self.on_update_data_path_event)
mil_project.update_item_file_path_signal.connect(self.on_update_file_path_event)
item.setSizeHint(QSize(mil_project.sizeHint().width(), 150))
self.listWidget.addItem(item)
self.listWidget.setCurrentItem(item)
self.listWidget.setItemWidget(item, mil_project)
pass
def add_pro_event(self, name):
item = QListWidgetItem()
mil_project = MILProject(self.config, name=name)
mil_project.update_item_name_signal.connect(self.update_config)
mil_project.update_item_data_path_signal.connect(self.on_update_data_path_event)
mil_project.update_item_file_path_signal.connect(self.on_update_file_path_event)
item.setSizeHint(QSize(mil_project.sizeHint().width(), 150))
self.listWidget.addItem(item)
self.listWidget.setCurrentItem(item)
self.listWidget.setItemWidget(item, mil_project)
def on_del_pro_event(self):
mil_project = self.listWidget.itemWidget(self.listWidget.currentItem())
if not isinstance(mil_project,MILProject):
raise "on_del_pro_event error!!!"
if self.config.CurrProject == mil_project.name:
self.config.CurrProject = ""
self.listWidget.takeItem(self.listWidget.currentRow())
self.config.ItemConfigs.pop(mil_project.name)
self.config.save_config()
self.update_config()
pass
def search_mil_pro_item(self, name):
for mil_project in self.listWidget.findChildren(MILProject):
if not isinstance(mil_project,MILProject):
return
if mil_project.name == name:
return mil_project
return None
def on_load_data_path(self):
data_path = self.DataPath.text()
if os.path.exists(data_path):
path,type = QFileDialog.getOpenFileName(self, "选择文件", data_path,"Excel工作簿(*.xlsx)")
else:
path,type = QFileDialog.getOpenFileName(self, "选择文件", os.getcwd(),"Excel工作簿(*.xlsx)")
self.DataPath.setText(path)
self.config.ItemConfigs[self.config.CurrProject]["DataPath"] = path
mil_project = self.search_mil_pro_item(self.config.CurrProject)
mil_project.lineEditDataPath.setText(path)
self.config.save_config()
def on_load_file_path(self):
file_path = self.FilePath.text()
if os.path.exists(file_path):
path,type = QFileDialog.getOpenFileName(self, "选择文件", file_path,"Excel工作簿(*.xlsx)")
else:
path,type = QFileDialog.getOpenFileName(self, "选择文件", os.getcwd(),"Excel工作簿(*.xlsx)")
self.FilePath.setText(path)
self.config.ItemConfigs[self.config.CurrProject]["FilePath"] = path
mil_project = self.search_mil_pro_item(self.config.CurrProject)
mil_project.lineEditFilePath.setText(path)
self.config.save_config()
+6 -2
View File
@@ -17,7 +17,7 @@
<item row="0" column="0">
<widget class="QTabWidget" name="tabWidget">
<property name="tabPosition">
<enum>QTabWidget::TabPosition::South</enum>
<enum>QTabWidget::TabPosition::North</enum>
</property>
<property name="currentIndex">
<number>0</number>
@@ -354,7 +354,11 @@
</attribute>
<layout class="QGridLayout" name="gridLayout_11">
<item row="0" column="0">
<widget class="QListWidget" name="listWidget"/>
<widget class="QListWidget" name="listWidget">
<property name="contextMenuPolicy">
<enum>Qt::ContextMenuPolicy::CustomContextMenu</enum>
</property>
</widget>
</item>
</layout>
</widget>
+2 -1
View File
@@ -29,7 +29,7 @@ class Ui_FrameMILTool(object):
self.gridLayout_8.setObjectName(u"gridLayout_8")
self.tabWidget = QTabWidget(FrameMILTool)
self.tabWidget.setObjectName(u"tabWidget")
self.tabWidget.setTabPosition(QTabWidget.TabPosition.South)
self.tabWidget.setTabPosition(QTabWidget.TabPosition.North)
self.widget = QWidget()
self.widget.setObjectName(u"widget")
self.gridLayout_12 = QGridLayout(self.widget)
@@ -272,6 +272,7 @@ class Ui_FrameMILTool(object):
self.gridLayout_11.setObjectName(u"gridLayout_11")
self.listWidget = QListWidget(self.tab_2)
self.listWidget.setObjectName(u"listWidget")
self.listWidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.gridLayout_11.addWidget(self.listWidget, 0, 0, 1, 1)
+1
View File
@@ -5,3 +5,4 @@ openpyxl>=3.0.0
# 开发依赖
pytest>=7.0.0
nuitka
+283
View File
@@ -0,0 +1,283 @@
"""导出插件分发包到 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()
+71
View File
@@ -0,0 +1,71 @@
"""把 export_runtime.py 产物打包成带版本号的 zip 分发包。
产物:dist/<tool_name>-<version>-<python_tag>.zip
zip 内部扁平结构:解压到 plugins/<tool_name>/ 即得完整目录。
用法:
python build_pyd.py --clean
python tools/export_runtime.py
python tools/pack_zip.py
"""
from __future__ import annotations
import json
import platform
import re
import sys
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PLUGINS_DIR = ROOT / "plugins"
DIST_DIR = ROOT / "dist"
def _read_plugin_meta() -> tuple[str, str]:
"""从 mil/__init__.py 源码提取插件名与版本号(绕过 PySide6 import)。"""
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)
return name, f"{major}.{minor}.{micro}"
def _python_tag() -> str:
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 main() -> None:
if not PLUGINS_DIR.exists() or not any(PLUGINS_DIR.iterdir()):
print("[pack] ERROR: plugins/ 不存在或为空,请先运行 export_runtime.py",
file=sys.stderr)
sys.exit(1)
name, version = _read_plugin_meta()
manifest = json.loads((PLUGINS_DIR / "manifest.json").read_text(encoding="utf-8"))
# 主业务包名由 export_runtime 写入 manifest,固定为 mil
tool_name = manifest["tool_name"]
zip_name = f"{tool_name}-{version}-{_python_tag()}.zip"
DIST_DIR.mkdir(parents=True, exist_ok=True)
zip_path = DIST_DIR / zip_name
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for f in PLUGINS_DIR.rglob("*"):
if f.is_file():
# 扁平结构:arcname 相对 plugins/,解压到子目录即得完整布局
arcname = f.relative_to(PLUGINS_DIR).as_posix()
zf.write(f, arcname)
file_count = len(zipfile.ZipFile(zip_path).namelist())
print(f"[pack] 打包完成: {zip_path}")
print(f"[pack] 插件: {name} v{version}")
print(f"[pack] 文件数: {file_count}")
print(f"[pack] 下一步: 把 zip 复制到远程 Y:/SE/xufeifei/plugins/")
if __name__ == "__main__":
main()
+118
View File
@@ -0,0 +1,118 @@
"""宿主启动时的 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(过渡期)。
注意:本文件不依赖任何第三方包,只用 stdlibos / 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) onedirexe 旁边的 plugins/
exe_dir = Path(sys.executable).resolve().parent
plugins = exe_dir / "plugins"
if plugins.is_dir():
return plugins.resolve()
# 3) onefilePyInstaller 解压目录旁边的 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()
+93
View File
@@ -0,0 +1,93 @@
"""编译完跑一遍:把 build/ 下的伴生包 .pyd 挂到 sys.modules
再用真实接口(openpyxl.load_workbook)写读一个最小 Excel,确认链路可用。
用法:
python tools/verify_companions.py
注意:本脚本直接 import mil.core.companion_loader**不**经过 mil/__init__.py
避免在无 PySide6 的开发机上因 UI 桥接而炸(生产宿主环境必然有 PySide6)。
"""
from __future__ import annotations
import argparse
import importlib.util
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
# 不走 mil/__init__.py,单独加载 companion_loader
spec = importlib.util.spec_from_file_location(
"mil_companion_loader", ROOT / "mil" / "core" / "companion_loader.py"
)
companion_loader = importlib.util.module_from_spec(spec)
spec.loader.exec_module(companion_loader)
load_companions = companion_loader.load_companions
# 伴生包清单:与 mil/__init__.py 中的 COMPANION_PACKAGES 保持一致
COMPANION_PACKAGES: list[str] = [
"openpyxl",
"et_xmlfile",
]
def main() -> int:
parser = argparse.ArgumentParser(description="验证伴生包 .pyd 加载链路")
parser.add_argument(
"--fallback",
action="store_true",
help="未发现产物时回退到 site-packages 已装版本(开发自测用)",
)
args = parser.parse_args()
loaded = load_companions(COMPANION_PACKAGES)
print(f"[verify] 加载器返回: {loaded}")
if not loaded and not args.fallback:
print(
"[verify] FAIL: 没有挂载到任何伴生包(是否忘了 python build_pyd.py?)\n"
" 调试期可加 --fallback 走 site-packages 自测加载器逻辑。",
file=sys.stderr,
)
return 1
if not loaded and args.fallback:
# 开发自测:build/mil_runtime 都没有时,从 sys.path 兜底
import importlib
for pkg in COMPANION_PACKAGES:
importlib.import_module(pkg)
loaded.append(pkg)
print(f"[verify] fallback 后挂载: {loaded}")
# 用 openpyxl 真接口验证:写一个最小 xlsx,立刻读回来
import openpyxl # noqa: WPS433 延迟导入以验证挂载生效
print(f"[verify] openpyxl 来自: {openpyxl.__file__}")
with tempfile.TemporaryDirectory() as td:
xlsx_path = Path(td) / "smoke.xlsx"
wb = openpyxl.Workbook()
ws = wb.active
ws["A1"] = "time"
ws["B1"] = "sig1"
ws["A2"] = 0.0
ws["B2"] = 1
ws["A3"] = 1.0
ws["B3"] = 2
wb.save(xlsx_path)
wb2 = openpyxl.load_workbook(xlsx_path)
ws2 = wb2.active
assert ws2["A1"].value == "time", ws2["A1"].value
assert ws2["B1"].value == "sig1", ws2["B1"].value
assert float(ws2["A3"].value) == 1.0, ws2["A3"].value
assert int(ws2["B3"].value) == 2, ws2["B3"].value
print("[verify] PASS: openpyxl 写读闭环 ✓")
return 0
if __name__ == "__main__":
sys.exit(main())