diff --git a/.gitignore b/.gitignore index d507ffd..229e413 100644 --- a/.gitignore +++ b/.gitignore @@ -175,4 +175,4 @@ cython_debug/ .pypirc *.xlsx *.md - +/plugins diff --git a/build_pyd.py b/build_pyd.py new file mode 100644 index 0000000..597cdbb --- /dev/null +++ b/build_pyd.py @@ -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/--.zip + 4. 复制 zip → Y:/SE/xufeifei/plugins/(--no-deploy 跳过) + +产物布局: + build/ + mil.cp311-win_amd64.pyd ← 业务包 + plugins/ ← export_runtime.py 生成(中间产物) + dist/ + mil--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,产出 ..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 都是纯 Python,Nuitka --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/..pyd ← Nuitka --module 默认 + # 2) build//..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() \ No newline at end of file diff --git a/mil.json b/mil.json index 1da7587..8ad3e0d 100644 --- a/mil.json +++ b/mil.json @@ -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" + } + } } \ No newline at end of file diff --git a/mil.pyi b/mil.pyi deleted file mode 100644 index 8254abb..0000000 --- a/mil.pyi +++ /dev/null @@ -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 \ No newline at end of file diff --git a/mil/__init__.py b/mil/__init__.py index 0a88dec..f12c997 100644 --- a/mil/__init__.py +++ b/mil/__init__.py @@ -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 = """ + +""" + +# 伴生包清单从 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", +] \ No newline at end of file diff --git a/mil/core/base.py b/mil/core/base.py index 5c1019c..f5a62fd 100644 --- a/mil/core/base.py +++ b/mil/core/base.py @@ -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: diff --git a/mil/core/companion_loader.py b/mil/core/companion_loader.py new file mode 100644 index 0000000..e5d3969 --- /dev/null +++ b/mil/core/companion_loader.py @@ -0,0 +1,106 @@ +"""伴生包加载器:把纯 Python 伴生包从产物目录挂到 sys.modules。 + +设计目标:宿主 exe 一次打包后,mil 的伴生依赖(openpyxl / et_xmlfile / ...) +可通过"覆盖 build// 下的 .pyd 或 mil_runtime// 下的源码"增量更新, +宿主不需要重打。 + +加载顺序(命中即停): +1. sys.modules 已存在(不动) +2. mil_runtime// 下的纯 Python 源码(数据化部署) +3. build// 下的预编译 .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,开发侧不一定有)。 + """ + # 本文件位于 /mil/core/companion_loader.py,往上两级就是 + return Path(__file__).resolve().parent.parent.parent + + +def _load_from_source(runtime_dir: Path, pkg: str) -> ModuleType | None: + """尝试从 mil_runtime// 加载纯 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} 构造 spec(runtime 源码),跳过") + 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//.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} 构造 spec(pyd={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 \ No newline at end of file diff --git a/mil/core/manifest.py b/mil/core/manifest.py new file mode 100644 index 0000000..aff2589 --- /dev/null +++ b/mil/core/manifest.py @@ -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 * +} diff --git a/mil/ui/mil_project.py b/mil/ui/mil_project.py index e69de29..4023ec2 100644 --- a/mil/ui/mil_project.py +++ b/mil/ui/mil_project.py @@ -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() \ No newline at end of file diff --git a/mil/ui/mil_project.ui b/mil/ui/mil_project.ui index 869731f..3c4409f 100644 --- a/mil/ui/mil_project.ui +++ b/mil/ui/mil_project.ui @@ -1,7 +1,7 @@ - FrameProject - + FrameMILProject + 0 diff --git a/mil/ui/mil_project_ui.py b/mil/ui/mil_project_ui.py index b0288bf..3af1fbb 100644 --- a/mil/ui/mil_project_ui.py +++ b/mil/ui/mil_project_ui.py @@ -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 diff --git a/mil/ui/mil_tool.py b/mil/ui/mil_tool.py index 08e4d88..a51a3c0 100644 --- a/mil/ui/mil_tool.py +++ b/mil/ui/mil_tool.py @@ -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() diff --git a/mil/ui/mil_tool.ui b/mil/ui/mil_tool.ui index 8f5f655..ae6ca4b 100644 --- a/mil/ui/mil_tool.ui +++ b/mil/ui/mil_tool.ui @@ -17,7 +17,7 @@ - QTabWidget::TabPosition::South + QTabWidget::TabPosition::North 0 @@ -354,7 +354,11 @@ - + + + Qt::ContextMenuPolicy::CustomContextMenu + + diff --git a/mil/ui/mil_tool_ui.py b/mil/ui/mil_tool_ui.py index 029f9dd..6e8117f 100644 --- a/mil/ui/mil_tool_ui.py +++ b/mil/ui/mil_tool_ui.py @@ -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) diff --git a/requirements.txt b/requirements.txt index c0099c0..3ce3663 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ openpyxl>=3.0.0 # 开发依赖 pytest>=7.0.0 +nuitka diff --git a/tools/export_runtime.py b/tools/export_runtime.py new file mode 100644 index 0000000..fe374b3 --- /dev/null +++ b/tools/export_runtime.py @@ -0,0 +1,283 @@ +"""导出插件分发包到 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() diff --git a/tools/pack_zip.py b/tools/pack_zip.py new file mode 100644 index 0000000..b5cd51d --- /dev/null +++ b/tools/pack_zip.py @@ -0,0 +1,71 @@ +"""把 export_runtime.py 产物打包成带版本号的 zip 分发包。 + +产物:dist/--.zip +zip 内部扁平结构:解压到 plugins// 即得完整目录。 + +用法: + 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() diff --git a/tools/runtime_hook.py b/tools/runtime_hook.py new file mode 100644 index 0000000..7612889 --- /dev/null +++ b/tools/runtime_hook.py @@ -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(过渡期)。 + +注意:本文件不依赖任何第三方包,只用 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() diff --git a/tools/verify_companions.py b/tools/verify_companions.py new file mode 100644 index 0000000..18ca586 --- /dev/null +++ b/tools/verify_companions.py @@ -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()) \ No newline at end of file