From f18805522878f4e49d29f727248b24c621597a2d Mon Sep 17 00:00:00 2001 From: "feifei.xu" <307327147@qq.com> Date: Tue, 21 Jul 2026 18:14:34 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=85=E7=90=86=E6=97=A0=E7=94=A8=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E3=80=81=E5=90=88=E5=B9=B6=E5=B7=A5=E5=85=B7=E9=93=BE?= =?UTF-8?q?=E3=80=81=E4=BF=AE=E5=A4=8D=E6=B3=A8=E9=87=8A=E4=B8=8E=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除破损文件: main.py/main.ui/main_ui.py - 删除 runtime_hook.py(归宿主维护),清除 tools/ 目录 - 合并 export_runtime/pack_zip/verify_companions 到 build_pyd.py - 修复注释: docstring 参数与实际签名不一致、过时引用、拼写错误 - 修复日志: 消除静默吞异常、删冗余 log+raise、补缺失日志 - 精简 .gitignore --- .gitignore | 112 +--------- build_pyd.py | 353 +++++++++++++++++++++++------- main.py | 19 -- main.ui | 52 ----- main_ui.py | 56 ----- mil/__init__.py | 4 +- mil/core/__init__.py | 2 +- mil/core/base.py | 32 +-- mil/core/companion_loader.py | 4 +- mil/core/manifest.py | 2 +- mil/core/mil_create_data_excel.py | 3 +- mil/core/mil_read_case_excel.py | 3 - mil/core/mil_read_data_excel.py | 15 +- mil/core/mil_update_excel.py | 1 - mil/ui/mil_project.py | 2 + mil/ui/mil_tool.py | 16 +- tools/export_runtime.py | 283 ------------------------ tools/pack_zip.py | 71 ------ tools/runtime_hook.py | 118 ---------- tools/verify_companions.py | 93 -------- 20 files changed, 305 insertions(+), 936 deletions(-) delete mode 100644 main.py delete mode 100644 main.ui delete mode 100644 main_ui.py delete mode 100644 tools/export_runtime.py delete mode 100644 tools/pack_zip.py delete mode 100644 tools/runtime_hook.py delete mode 100644 tools/verify_companions.py diff --git a/.gitignore b/.gitignore index 229e413..1d29aae 100644 --- a/.gitignore +++ b/.gitignore @@ -28,8 +28,6 @@ share/python-wheels/ MANIFEST # PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec @@ -52,82 +50,6 @@ coverage.xml .pytest_cache/ cover/ -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - # Environments .env .venv @@ -137,40 +59,20 @@ ENV/ env.bak/ venv.bak/ -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - # mypy .mypy_cache/ .dmypy.json dmypy.json -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Ruff stuff: +# Ruff .ruff_cache/ +# UV +.pdm.toml +.pdm-python +.pdm-build/ +__pypackages__/ + # PyPI configuration file .pypirc *.xlsx diff --git a/build_pyd.py b/build_pyd.py index 597cdbb..9c5813d 100644 --- a/build_pyd.py +++ b/build_pyd.py @@ -1,8 +1,8 @@ -"""通过 Nuitka 把 mil 包及其纯 Python 伴生包编译为 .pyd。 +"""通过 Nuitka 把 mil 包及其纯 Python 伴生包编译为 .pyd,并完成导出、打包、部署、验证全流程。 策略说明: - **mil 业务包**(mil/ 整个包:core + ui)→ 编 pyd; -- **Qt 桥接代码**(mil/ui/*_ui.py、main_ui.py)→ 保留为源码; +- **Qt 桥接代码**(mil/ui/*_ui.py)→ 保留为源码; - **纯 Python 伴生包**(COMPANIONS 列表)→ 逐个编 pyd,产物随 mil 一起分发; - **Qt .ui 资源文件** → 运行时由 mil 包内的 uic.loadUiType 直接读源码路径; - **PySide6** → --nofollow-import-to,运行时走宿主 pip 安装的二进制。 @@ -19,47 +19,91 @@ python build_pyd.py --no-progress # 关闭进度条(CI 友好) python build_pyd.py --only mil # 只编指定包(逗号分隔) python build_pyd.py --no-deploy # 不复制 zip 到网络盘 + python build_pyd.py --verify # 单独验证伴生包加载链路 + python build_pyd.py --verify --fallback # 验证时允许回退到 site-packages 全流程四步: 1. Nuitka 编译 .pyd → build/ - 2. export_runtime 聚合 → plugins/ - 3. pack_zip 打包 → dist/--.zip + 2. export 聚合 → plugins/ + 3. pack 打包 → dist/--.zip 4. 复制 zip → Y:/SE/xufeifei/plugins/(--no-deploy 跳过) 产物布局: build/ mil.cp311-win_amd64.pyd ← 业务包 - plugins/ ← export_runtime.py 生成(中间产物) + plugins/ ← 中间产物 dist/ - mil--cp311-win_amd64.zip ← pack_zip.py 生成(最终交付物) + mil--cp311-win_amd64.zip ← 最终交付物 """ from __future__ import annotations import argparse +import importlib.util +import json +import platform +import re import shutil import subprocess import sys +import tempfile +import zipfile from pathlib import Path ROOT = Path(__file__).resolve().parent BUILD_DIR = ROOT / "build" +PLUGINS_DIR = ROOT / "plugins" DIST_DIR = ROOT / "dist" REMOTE_DEPLOY_DIR = Path("Y:/SE/xufeifei/plugins") +# ============================================================ +# 共享工具函数 +# ============================================================ + +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 _read_plugin_meta() -> tuple[str, 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) + 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 + + +# ============================================================ +# 伴生包清单(从 manifest.py 加载,绕过 mil/__init__.py 的 PySide6 依赖) +# ============================================================ +_spec = importlib.util.spec_from_file_location( + "mil_manifest", ROOT / "mil" / "core" / "manifest.py" +) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) +COMPANIONS: list[str] = _mod.COMPANION_PACKAGES_PYD + + +# ============================================================ +# Nuitka 编译 +# ============================================================ + 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) @@ -70,49 +114,31 @@ def _resolve_entry(pkg: str) -> Path: 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) # 入口解析保留,仅用于校验包存在 + entry = _resolve_entry(pkg) pkg_dir = entry.parent pkg_name = pkg_dir.name - is_local = pkg_dir.parent == ROOT # 工程内业务包 vs site-packages 第三方包 + is_local = pkg_dir.parent == ROOT - # 末尾带 / —— Nuitka 区分"目录 vs 文件"的关键 module_arg = str(pkg_dir) + "/" cmd: list[str] = [ sys.executable, "-m", "nuitka", "--module", - module_arg, # ← 包目录,不是 __init__.py + module_arg, f"--include-package={pkg_name}", - f"--output-dir={BUILD_DIR}", # 绝对路径,避免被 cwd 影响 + f"--output-dir={BUILD_DIR}", ] - # 仅对第三方包显式排除 stdlib 子模块,避免半截包问题。 - # 运行时这些 stdlib 走宿主 Python 自带版本(任何合规安装都有)。 if not is_local: cmd += [ - "--nofollow-import-to=xml", # openpyxl/xml/functions.py:40 用到 iterparse + "--nofollow-import-to=xml", "--nofollow-import-to=xml.etree", "--nofollow-import-to=xml.etree.ElementTree", "--nofollow-import-to=zipfile", @@ -125,39 +151,22 @@ def build_one(pkg: str, no_progress: bool) -> Path: "--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")) @@ -168,33 +177,134 @@ def build_one(pkg: str, no_progress: bool) -> Path: return candidates[0] -def _export_and_pack() -> None: - """编译完成后,串调 export_runtime + pack_zip 生成最终 zip。 +# ============================================================ +# 导出 plugins/ 分发包(原 tools/export_runtime.py) +# ============================================================ - 两个工具脚本都暴露 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)) +def _copy_pyd_files() -> list[str]: + """把 build/ 下的 .pyd 拷到 plugins/,返回已拷贝的包名列表。""" + copied: list[str] = [] + for pkg in _mod.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 - try: - print("[build_pyd] 步骤 2/4: 导出 plugins/ 分发包") - import export_runtime - export_runtime.main() + dst = PLUGINS_DIR / matches[0].name + shutil.copy2(matches[0], dst) + copied.append(pkg) + print(f"[export] {pkg}: {matches[0].name} -> {dst}") - 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 + return copied +def _copy_c_extensions() -> list[str]: + """从 Python 安装目录拷贝 C 扩展 .pyd 到 plugins/。""" + copied: list[str] = [] + py_root = Path(sys.base_prefix) + search_dirs = [py_root, py_root / "DLLs"] + + for stdlib_pkg, ext_names in _mod.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 _write_manifest(copied_packages: list[str], stdlib_packages: list[str], + c_extensions: list[str]) -> None: + """生成 plugins/manifest.json,供宿主 runtime hook 读取并配置 sys.path。""" + 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, + "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 _export_runtime() -> None: + """导出插件分发包到 plugins/ 目录。""" + if not BUILD_DIR.exists(): + print("[export] ERROR: build/ 不存在,请先运行 python build_pyd.py", file=sys.stderr) + raise SystemExit(1) + + if PLUGINS_DIR.exists(): + shutil.rmtree(PLUGINS_DIR) + PLUGINS_DIR.mkdir(parents=True) + + copied_pyd = _copy_pyd_files() + 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)} 个(含已编入 mil.pyd 的 openpyxl/et_xmlfile)") + print(f"[export] C 扩展: {len(copied_cext)} 个(运行时从插件目录加载)") + print(f"[export] Python 版本: {platform.python_version()}") + print(f"[export] 下一步: 把 plugins/ 目录放到宿主 exe 旁边") + + +# ============================================================ +# 打包 zip 分发包(原 tools/pack_zip.py) +# ============================================================ + +def _pack_zip() -> None: + """把 plugins/ 目录打包成带版本号的 zip 分发包。""" + if not PLUGINS_DIR.exists() or not any(PLUGINS_DIR.iterdir()): + print("[pack] ERROR: plugins/ 不存在或为空,请先执行导出步骤", file=sys.stderr) + raise SystemExit(1) + + name, version, _ = _read_plugin_meta() + manifest = json.loads((PLUGINS_DIR / "manifest.json").read_text(encoding="utf-8")) + 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 = 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 复制到远程 {REMOTE_DEPLOY_DIR}") + + +# ============================================================ +# 部署 +# ============================================================ + def _deploy_zip() -> None: """把 dist/ 下最新生成的 zip 复制到网络盘远程分发目录。""" zips = sorted(DIST_DIR.glob("*.zip"), key=lambda f: f.stat().st_mtime, reverse=True) @@ -211,19 +321,92 @@ def _deploy_zip() -> None: print(f"[build_pyd] 步骤 4/4: 已部署 {src.name} -> {dst}") +# ============================================================ +# 验证伴生包加载链路(原 tools/verify_companions.py) +# ============================================================ + +def verify_companions(fallback: bool = False) -> int: + """验证 build/ 下伴生包 .pyd 的 openpyxl 读写闭环是否可用。 + + 直接 import mil.core.companion_loader,不经过 mil/__init__.py, + 避免在无 PySide6 的开发机上因 UI 桥接而炸。 + """ + spec = importlib.util.spec_from_file_location( + "mil_companion_loader", ROOT / "mil" / "core" / "companion_loader.py" + ) + loader_mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(loader_mod) + load_companions = loader_mod.load_companions + + packages = list(_mod.COMPANION_PACKAGES) + loaded = load_companions(packages) + print(f"[verify] 加载器返回: {loaded}") + + if not loaded and not fallback: + print( + "[verify] FAIL: 没有挂载到任何伴生包(是否忘了 python build_pyd.py?)\n" + " 调试期可加 --fallback 走 site-packages 自测加载器逻辑。", + file=sys.stderr, + ) + return 1 + + if not loaded and fallback: + import importlib as _il + for pkg in packages: + _il.import_module(pkg) + loaded.append(pkg) + print(f"[verify] fallback 后挂载: {loaded}") + + 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 + + +# ============================================================ +# 主入口 +# ============================================================ + def main() -> None: + """编译 mil 包及其伴生依赖为 .pyd,导出为 plugins/ 目录,打包为 zip 并部署至网络盘。 + + 通过 --verify 可跳过编译,仅验证已编译产物的 openpyxl 读写闭环。 + """ 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;默认全编", - ) + parser.add_argument("--only", type=str, default="", + help="只编指定包(逗号分隔),如 --only mil,openpyxl;默认全编") + parser.add_argument("--verify", action="store_true", help="验证伴生包 .pyd 加载链路(不编译)") + parser.add_argument("--fallback", action="store_true", + help="验证时,未发现产物则回退到 site-packages(开发自测用)") args = parser.parse_args() + if args.verify: + sys.exit(verify_companions(args.fallback)) + if args.clean and BUILD_DIR.exists(): shutil.rmtree(BUILD_DIR) @@ -239,10 +422,20 @@ def main() -> None: out = build_one(pkg, args.no_progress) print(f"[build_pyd] {pkg} -> {out}") - ok = _export_and_pack() - if ok and not args.no_deploy: + try: + print("[build_pyd] 步骤 2/4: 导出 plugins/ 分发包") + _export_runtime() + + print("[build_pyd] 步骤 3/4: 打包 zip") + _pack_zip() + except SystemExit as e: + if e.code != 0: + print(f"[build_pyd] 打包流程中止(退出码 {e.code})", file=sys.stderr) + sys.exit(e.code) + + if not args.no_deploy: _deploy_zip() if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/main.py b/main.py deleted file mode 100644 index a092ced..0000000 --- a/main.py +++ /dev/null @@ -1,19 +0,0 @@ -from main_ui import Ui_MainWindow -from PySide6.QtWidgets import QApplication, QMainWindow -import sys -from qt_material import apply_stylesheet - - -class MainWindow(QMainWindow,Ui_MainWindow): - def __init__(self): - super().__init__() - self.setupUi(self) - - apply_stylesheet(self, style=None,theme='dark_teal.xml') - - -if __name__ == '__main__': - app = QApplication(sys.argv) - main: MainWindow = MainWindow() - main.show() - sys.exit(app.exec()) \ No newline at end of file diff --git a/main.ui b/main.ui deleted file mode 100644 index e514374..0000000 --- a/main.ui +++ /dev/null @@ -1,52 +0,0 @@ - - - MainWindow - - - - 0 - 0 - 800 - 600 - - - - MainWindow - - - - - - - QFrame::Shape::StyledPanel - - - QFrame::Shadow::Raised - - - - - - - - - 0 - 0 - 800 - 21 - - - - - - - - FrameMILTool - QFrame -
src.h
- 1 -
-
- - -
diff --git a/main_ui.py b/main_ui.py deleted file mode 100644 index cc26f8e..0000000 --- a/main_ui.py +++ /dev/null @@ -1,56 +0,0 @@ -# -*- coding: utf-8 -*- - -################################################################################ -## Form generated from reading UI file 'main.ui' -## -## Created by: Qt User Interface Compiler version 6.11.1 -## -## WARNING! All changes made in this file will be lost when recompiling UI file! -################################################################################ - -from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, - QMetaObject, QObject, QPoint, QRect, - QSize, QTime, QUrl, Qt) -from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, - QFont, QFontDatabase, QGradient, QIcon, - QImage, QKeySequence, QLinearGradient, QPainter, - QPalette, QPixmap, QRadialGradient, QTransform) -from PySide6.QtWidgets import (QApplication, QFrame, QGridLayout, QMainWindow, - QMenuBar, QSizePolicy, QStatusBar, QWidget) - -from mil import FrameMILTool - -class Ui_MainWindow(object): - def setupUi(self, MainWindow): - if not MainWindow.objectName(): - MainWindow.setObjectName(u"MainWindow") - MainWindow.resize(800, 600) - self.centralwidget = QWidget(MainWindow) - self.centralwidget.setObjectName(u"centralwidget") - self.gridLayout = QGridLayout(self.centralwidget) - self.gridLayout.setObjectName(u"gridLayout") - self.frame = FrameMILTool(self.centralwidget) - self.frame.setObjectName(u"frame") - self.frame.setFrameShape(QFrame.Shape.StyledPanel) - self.frame.setFrameShadow(QFrame.Shadow.Raised) - - self.gridLayout.addWidget(self.frame, 0, 0, 1, 1) - - MainWindow.setCentralWidget(self.centralwidget) - self.menubar = QMenuBar(MainWindow) - self.menubar.setObjectName(u"menubar") - self.menubar.setGeometry(QRect(0, 0, 800, 21)) - MainWindow.setMenuBar(self.menubar) - self.statusbar = QStatusBar(MainWindow) - self.statusbar.setObjectName(u"statusbar") - MainWindow.setStatusBar(self.statusbar) - - self.retranslateUi(MainWindow) - - QMetaObject.connectSlotsByName(MainWindow) - # setupUi - - def retranslateUi(self, MainWindow): - MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", u"MainWindow", None)) - # retranslateUi - diff --git a/mil/__init__.py b/mil/__init__.py index f12c997..267abd8 100644 --- a/mil/__init__.py +++ b/mil/__init__.py @@ -9,13 +9,13 @@ NMAE = "MIL Tool" # 工具版本 __MAJOR_VER: int = 0 # 主版本号 __MINOR_VER: int = 0 # 次版本号 -__MICRO_VER: int = 4 # 修订版本号 +__MICRO_VER: int = 6 # 修订版本号 VERSION:str = f"{__MAJOR_VER}.{__MINOR_VER}.{__MICRO_VER}" # 工具描述 DESCRIPITION = """ -主要用于生成Simlink Test Case。 +主要用于生成Simulink Test Case。 """ SVG = """ diff --git a/mil/core/__init__.py b/mil/core/__init__.py index 76fdfad..d8d4f05 100644 --- a/mil/core/__init__.py +++ b/mil/core/__init__.py @@ -3,7 +3,7 @@ 提供 MIL 仿真数据读取功能 使用示例: - from src.core import read_excel_data + from mil.core import read_excel_data result = read_excel_data("simulation.xlsx") """ diff --git a/mil/core/base.py b/mil/core/base.py index f5a62fd..6b4e113 100644 --- a/mil/core/base.py +++ b/mil/core/base.py @@ -16,7 +16,8 @@ class Config(): 配置传输载体。序列化使用 JSON 文件持久化。 Attributes: - DataPath: 仿真数据目录(Excel 原始文件所在路径) + path: 配置文件路径(JSON 文件) + DataPath: 仿真数据目录 FilePath: 当前打开的 Excel 文件路径 AddTimeEn: 用例步骤时间是否按累加方式记录 GeratePath: 是否为生成的用例另存新文件 @@ -47,16 +48,13 @@ class Config(): } def load_config(self) -> "Config": - """从 JSON 文件读取配置并填充到当前实例的各个字段。 + """从 self.path 指定的 JSON 文件读取配置并填充到当前实例的各个字段。 解析规则: - JSON 中存在的字段会被回写到 Config 的对应字段; - JSON 中缺失的字段保持当前 Config 实例的默认值; - JSON 中多余字段被忽略(向前兼容:增加字段不会破坏老配置)。 - Args: - config_path: 配置文件路径。 - Returns: self:填充后的 Config 实例,便于链式调用。 @@ -85,10 +83,7 @@ class Config(): return self def save_config(self): - """将当前配置以 JSON 格式写入磁盘。 - - Args: - config_path: 配置文件路径。 + """将当前配置以 JSON 格式写入 self.path 指定的文件。 Raises: Exception: 写入失败时记录日志并原样抛出异常。 @@ -97,7 +92,8 @@ class Config(): 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"配置文件{self.path}写入失败{e.args}") + logger.error(f"配置文件{self.path}写入失败: {e}") + raise @dataclass class DataLog: @@ -105,7 +101,7 @@ class DataLog: Attributes: time: 时间戳(秒) - value: 信号值(可以是任意类型) + value: 信号值(字符串形式,写入 Excel 时按内容推断 int/str 类型) """ time: float = 0.0 value: str = "" @@ -124,7 +120,6 @@ class SignalData: attributes: 列上方各属性行(行号 -> 文本)的字典映射 datalog: 时间戳-数值采样点列表 """ - # signal_type: str | None = None column: int = 0 attributes: dict[str, str] = field(default_factory=dict) datalog: list[DataLog] = field(default_factory=list) @@ -177,16 +172,3 @@ class ExcelDataResult: """ return list(self.signals.keys()) - def to_dict(self) -> dict[str, Any]: - """转换为字典格式(兼容旧 API) - - Returns: - 包含所有信号的字典,保留原有的数据结构 - """ - result = { - "sheet_name": self.sheet_name, - "source_row": self.source_row - } - for name, signal in self.signals.items(): - result[name] = signal.to_dict() - return result \ No newline at end of file diff --git a/mil/core/companion_loader.py b/mil/core/companion_loader.py index e5d3969..07b927d 100644 --- a/mil/core/companion_loader.py +++ b/mil/core/companion_loader.py @@ -61,7 +61,7 @@ def _load_from_source(runtime_dir: Path, pkg: str) -> ModuleType | None: def _load_from_pyd(build_dir: Path, pkg: str) -> ModuleType | None: - """尝试从 build//.cp311-*.pyd 加载预编译包。""" + """尝试从 build//..pyd 加载预编译包。""" candidates = list(build_dir.glob(f"{pkg}.*.pyd")) if not candidates: return None @@ -87,7 +87,6 @@ def load_companions(packages: list[str]) -> list[str]: loaded: list[str] = [] for pkg in packages: if pkg in sys.modules: - logger.debug(f"伴生包 {pkg} 已在 sys.modules,跳过") loaded.append(pkg) continue @@ -102,5 +101,4 @@ def load_companions(packages: list[str]) -> list[str]: 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 index aff2589..01f3746 100644 --- a/mil/core/manifest.py +++ b/mil/core/manifest.py @@ -3,7 +3,7 @@ 这是唯一的事实源: - 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 扩展。 +- build_pyd.py 导出步骤读 COMPANION_C_EXTENSIONS 决定要拷哪些 C 扩展。 加新伴生包时只动这里,其它脚本自动跟随。 """ diff --git a/mil/core/mil_create_data_excel.py b/mil/core/mil_create_data_excel.py index 0ee6b46..9ec51ff 100644 --- a/mil/core/mil_create_data_excel.py +++ b/mil/core/mil_create_data_excel.py @@ -133,10 +133,9 @@ def _analysis_case(data_dict: dict, sheets_dict: dict) -> CaseResultDict: for case in sheet_dict.keys(): try: if sheet_dict[case]["enable"]: - # 已完成的用例跳过生成 continue except KeyError: - # 缺少 enable 字段:保守跳过 + logger.warning(f"用例 '{case}' 缺少 enable 字段,跳过生成") continue # 拷贝数据并重置 datalog,然后按步骤逐条填充 diff --git a/mil/core/mil_read_case_excel.py b/mil/core/mil_read_case_excel.py index 45e4060..df5a811 100644 --- a/mil/core/mil_read_case_excel.py +++ b/mil/core/mil_read_case_excel.py @@ -86,19 +86,16 @@ def read_excel_case( except FileNotFoundError: raise ExcelReadError(f"Excel文件 {excel_path} 不存在") except Exception as e: - logger.error(f"读取 Excel 文件失败: {e}") raise ExcelReadError(f"无法读取Excel文件 {excel_path}: {str(e)}") # 版本表 Atech-Hefei 必须存在;解析后将其从工作簿移除,避免进入后续遍历 try: version = _get_template_version(wb["Atech-Hefei"]) if version is None: - logger.error("缺少模板版本号") raise ExcelFormatError("Excel格式错误,缺少模板版本号") logger.info(f"用例模板版本号: {version}") del wb["Atech-Hefei"] except KeyError: - logger.error(f"缺少 'Atech-Hefei' 表") raise ExcelFormatError(f"Excel文件 {excel_path} 格式错误,缺少 Atech-Hefei 表") result_dict: CaseDict = {} diff --git a/mil/core/mil_read_data_excel.py b/mil/core/mil_read_data_excel.py index cb3b9c8..818a749 100644 --- a/mil/core/mil_read_data_excel.py +++ b/mil/core/mil_read_data_excel.py @@ -32,23 +32,14 @@ class ExcelReaderConfig: source_header: 数据源标记行文本,默认 "Source: Input" time_column: 时间列索引,默认 1(A 列) header_row: 信号名称所在行号,默认 1 - type_row: 信号类型所在行号,默认 3 - interp_row: 插值策略所在行号,默认 6 data_start_row_offset: 相对于 source_row 的数据起始行偏移量,默认 1 - output_header: 输出数据标记行文本,默认 "Source: Output" - block_path_row: BlockPath 属性所在行号,默认 4 """ sheet_name: str = "Scenario1" source_header: str = "Source: Input" time_column: int = 1 header_row: int = 1 - type_row: int = 3 - interp_row: int = 6 data_start_row_offset: int = 1 - output_header: str = "Source: Output" - block_path_row: int = 4 - def read_excel_data( excel_path: str, @@ -87,17 +78,14 @@ def read_excel_data( wb = load_workbook(excel_path) logger.info(f"文件加载成功,Sheet 数量: {len(wb.sheetnames)}") except FileNotFoundError: - logger.error(f"文件不存在: {excel_path}") raise ExcelReadError(f"Excel文件 {excel_path} 不存在") except Exception as e: - logger.error(f"读取文件失败: {e}") raise ExcelReadError(f"读取Excel文件 {excel_path} 失败: {e}") # 2) 取出约定名称的工作表,KeyError 表示工作表缺失,视为格式错误 try: sheet = wb[config.sheet_name] except KeyError: - logger.error(f"缺少 '{config.sheet_name}' 表") raise ExcelFormatError(f"Excel文件 {excel_path} 缺少 '{config.sheet_name}' 表") # 3) 自第 1 行起纵向扫描 "Source: Input" 标记行 @@ -108,7 +96,6 @@ def read_excel_data( source_row += 1 # 扫描到表格末尾仍未命中,认定为格式错误 if source_row >= sheet.max_row: - logger.error(f"缺少 {config.source_header} 标记") raise ExcelFormatError(f"Excel格式错误,缺少 {config.source_header}") logger.debug(f"找到 {config.source_header} 标记,行号: {source_row}") @@ -177,7 +164,7 @@ def __get_signal_attributes( Args: sheet: Worksheet 对象 column: 列号 - max_row: 信号名行(不含)以内的最大行号 + max_row: Source: Input 标记行(不包含),即属性区域的上界 Returns: 信号属性字典,键为行号(int),值为该单元格文本(str) diff --git a/mil/core/mil_update_excel.py b/mil/core/mil_update_excel.py index d231809..1c67faa 100644 --- a/mil/core/mil_update_excel.py +++ b/mil/core/mil_update_excel.py @@ -44,7 +44,6 @@ def update_case_excel(filename: str, old_data: dict, new_data: dict) -> None: new_source_row = new_data.pop('source_row') if not isinstance(old_wb, Workbook) or not isinstance(old_sheet, Worksheet): - logger.error("数据类型错误,Workbook 或 Worksheet 类型不匹配") raise CaseDataError("数据异常,Workbook 或 Worksheet 类型错误") # 计算"新增"与"删除"两组信号 diff --git a/mil/ui/mil_project.py b/mil/ui/mil_project.py index 4023ec2..954f09b 100644 --- a/mil/ui/mil_project.py +++ b/mil/ui/mil_project.py @@ -106,6 +106,7 @@ class MILProject(QFrame, Ui_FrameMILProject): else: path,type = QFileDialog.getOpenFileName(self, "选择文件", "./","Excel工作簿(*.xlsx)") if not os.path.exists(path): + logger.debug(f"数据文件选择已取消: {dataPath}") return self.lineEditDataPath.setText(path) self.on_datapath_editing_finished_event() @@ -117,6 +118,7 @@ class MILProject(QFrame, Ui_FrameMILProject): else: path,type = QFileDialog.getOpenFileName(self, "选择文件", "./","Excel工作簿(*.xlsx)") if not os.path.exists(path): + logger.debug(f"测试用例文件选择已取消: {filePath}") return self.lineEditFilePath.setText(path) self.on_filepath_editing_finished_event() \ No newline at end of file diff --git a/mil/ui/mil_tool.py b/mil/ui/mil_tool.py index a51a3c0..220c12f 100644 --- a/mil/ui/mil_tool.py +++ b/mil/ui/mil_tool.py @@ -11,8 +11,8 @@ from .mil_project import MILProject from mil.core import Config - logger = logging.getLogger(__name__) + CONF = "mil.json" class FrameMILTool(QFrame,Ui_FrameMILTool): @@ -109,12 +109,11 @@ class FrameMILTool(QFrame,Ui_FrameMILTool): if text == "" or self.lock_event: return - self.config.CurrProject= text + 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() @@ -127,7 +126,6 @@ class FrameMILTool(QFrame,Ui_FrameMILTool): self.listWidget.addItem(item) self.listWidget.setCurrentItem(item) self.listWidget.setItemWidget(item, mil_project) - pass def add_pro_event(self, name): item = QListWidgetItem() @@ -143,8 +141,9 @@ class FrameMILTool(QFrame,Ui_FrameMILTool): 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 not isinstance(mil_project, MILProject): + logger.error("on_del_pro_event: 当前项不是 MILProject 实例") + return if self.config.CurrProject == mil_project.name: self.config.CurrProject = "" @@ -154,7 +153,6 @@ class FrameMILTool(QFrame,Ui_FrameMILTool): self.config.save_config() self.update_config() - pass def search_mil_pro_item(self, name): for mil_project in self.listWidget.findChildren(MILProject): @@ -171,6 +169,8 @@ class FrameMILTool(QFrame,Ui_FrameMILTool): else: path,type = QFileDialog.getOpenFileName(self, "选择文件", os.getcwd(),"Excel工作簿(*.xlsx)") self.DataPath.setText(path) + if not path: + return self.config.ItemConfigs[self.config.CurrProject]["DataPath"] = path mil_project = self.search_mil_pro_item(self.config.CurrProject) @@ -185,6 +185,8 @@ class FrameMILTool(QFrame,Ui_FrameMILTool): else: path,type = QFileDialog.getOpenFileName(self, "选择文件", os.getcwd(),"Excel工作簿(*.xlsx)") self.FilePath.setText(path) + if not path: + return self.config.ItemConfigs[self.config.CurrProject]["FilePath"] = path mil_project = self.search_mil_pro_item(self.config.CurrProject) diff --git a/tools/export_runtime.py b/tools/export_runtime.py deleted file mode 100644 index fe374b3..0000000 --- a/tools/export_runtime.py +++ /dev/null @@ -1,283 +0,0 @@ -"""导出插件分发包到 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 deleted file mode 100644 index b5cd51d..0000000 --- a/tools/pack_zip.py +++ /dev/null @@ -1,71 +0,0 @@ -"""把 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 deleted file mode 100644 index 7612889..0000000 --- a/tools/runtime_hook.py +++ /dev/null @@ -1,118 +0,0 @@ -"""宿主启动时的 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 deleted file mode 100644 index 18ca586..0000000 --- a/tools/verify_companions.py +++ /dev/null @@ -1,93 +0,0 @@ -"""编译完跑一遍:把 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