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
+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 *
}