清理无用代码、合并工具链、修复注释与日志问题
- 删除破损文件: main.py/main.ui/main_ui.py - 删除 runtime_hook.py(归宿主维护),清除 tools/ 目录 - 合并 export_runtime/pack_zip/verify_companions 到 build_pyd.py - 修复注释: docstring 参数与实际签名不一致、过时引用、拼写错误 - 修复日志: 消除静默吞异常、删冗余 log+raise、补缺失日志 - 精简 .gitignore
This commit is contained in:
+7
-105
@@ -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
|
||||
|
||||
+272
-79
@@ -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/<tool>-<ver>-<tag>.zip
|
||||
2. export 聚合 → plugins/
|
||||
3. pack 打包 → dist/<tool>-<ver>-<tag>.zip
|
||||
4. 复制 zip → Y:/SE/xufeifei/plugins/(--no-deploy 跳过)
|
||||
|
||||
产物布局:
|
||||
build/
|
||||
mil.cp311-win_amd64.pyd ← 业务包
|
||||
plugins/ ← export_runtime.py 生成(中间产物)
|
||||
plugins/ ← 中间产物
|
||||
dist/
|
||||
mil-<version>-cp311-win_amd64.zip ← pack_zip.py 生成(最终交付物)
|
||||
mil-<version>-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,产出 <pkg>.<python-tag>.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/<pkg>.<python-tag>.pyd ← Nuitka --module 默认
|
||||
# 2) build/<pkg>/<pkg>.<python-tag>.pyd ← 旧版本 / 某些参数组合
|
||||
candidates = list(BUILD_DIR.glob(f"{pkg_name}.*.pyd"))
|
||||
if not candidates:
|
||||
candidates = list((BUILD_DIR / pkg_name).glob(f"{pkg_name}.*.pyd"))
|
||||
@@ -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,8 +422,18 @@ 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()
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -1,52 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>MainWindow</class>
|
||||
<widget class="QMainWindow" name="MainWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>800</width>
|
||||
<height>600</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>MainWindow</string>
|
||||
</property>
|
||||
<widget class="QWidget" name="centralwidget">
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="FrameMILTool" name="frame">
|
||||
<property name="frameShape">
|
||||
<enum>QFrame::Shape::StyledPanel</enum>
|
||||
</property>
|
||||
<property name="frameShadow">
|
||||
<enum>QFrame::Shadow::Raised</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QMenuBar" name="menubar">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>800</width>
|
||||
<height>21</height>
|
||||
</rect>
|
||||
</property>
|
||||
</widget>
|
||||
<widget class="QStatusBar" name="statusbar"/>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>FrameMILTool</class>
|
||||
<extends>QFrame</extends>
|
||||
<header>src.h</header>
|
||||
<container>1</container>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
-56
@@ -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
|
||||
|
||||
+2
-2
@@ -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 = """
|
||||
|
||||
@@ -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")
|
||||
"""
|
||||
|
||||
+7
-25
@@ -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
|
||||
@@ -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/<pkg>/<pkg>.cp311-*.pyd 加载预编译包。"""
|
||||
"""尝试从 build/<pkg>/<pkg>.<python-tag>.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
|
||||
@@ -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 扩展。
|
||||
|
||||
加新伴生包时只动这里,其它脚本自动跟随。
|
||||
"""
|
||||
|
||||
@@ -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,然后按步骤逐条填充
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 类型错误")
|
||||
|
||||
# 计算"新增"与"删除"两组信号
|
||||
|
||||
@@ -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()
|
||||
+9
-7
@@ -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)
|
||||
|
||||
@@ -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. 文件系统目录(<python>/Lib/xml/)→ shutil.copytree
|
||||
2. zip 压缩包(python310.zip/xml/)→ zipfile 提取
|
||||
"""
|
||||
copied: list[str] = []
|
||||
seen: set[str] = set()
|
||||
stdlib_zip = _find_stdlib_zip()
|
||||
|
||||
for module_name in _manifest.all_stdlib_modules():
|
||||
top = module_name.split(".")[0]
|
||||
if top in seen:
|
||||
continue
|
||||
seen.add(top)
|
||||
|
||||
dst = PLUGINS_DIR / top
|
||||
if dst.exists():
|
||||
shutil.rmtree(dst)
|
||||
|
||||
# 优先从文件系统找
|
||||
try:
|
||||
import importlib.util
|
||||
spec = importlib.util.find_spec(top)
|
||||
if spec and spec.submodule_search_locations:
|
||||
src = Path(spec.submodule_search_locations[0])
|
||||
if src.is_dir() and ".zip" not in str(src):
|
||||
if _copy_stdlib_from_fs(top, src):
|
||||
copied.append(top)
|
||||
continue
|
||||
except (ImportError, ValueError):
|
||||
pass
|
||||
|
||||
# 从 zip 提取
|
||||
if stdlib_zip:
|
||||
if _extract_from_zip(stdlib_zip, top, dst):
|
||||
copied.append(top)
|
||||
continue
|
||||
|
||||
print(f"[export] WARN: 找不到 stdlib 包 {top}(fs 和 zip 均无)")
|
||||
|
||||
return copied
|
||||
|
||||
|
||||
def _copy_py_packages() -> list[str]:
|
||||
"""把 COMPANION_PACKAGES_PY 声明的包从 site-packages 拷 .py 源码到 plugins/。
|
||||
|
||||
这些包不编译 .pyd,保留 .py 源码形式,import 走标准 importlib 机制,
|
||||
能正确从 sys.path 找到插件自带的 stdlib xml。
|
||||
"""
|
||||
copied: list[str] = []
|
||||
for pkg in _manifest.COMPANION_PACKAGES_PY:
|
||||
try:
|
||||
import importlib.util
|
||||
spec = importlib.util.find_spec(pkg)
|
||||
if spec is None or not spec.submodule_search_locations:
|
||||
print(f"[export] WARN: 找不到包 {pkg} 的源码目录")
|
||||
continue
|
||||
src = Path(spec.submodule_search_locations[0])
|
||||
if not src.is_dir():
|
||||
print(f"[export] WARN: {pkg} 源码路径不是目录: {src}")
|
||||
continue
|
||||
dst = PLUGINS_DIR / pkg
|
||||
# 排除 __pycache__,只拷 .py 文件
|
||||
shutil.copytree(src, dst,
|
||||
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
||||
copied.append(pkg)
|
||||
print(f"[export] {pkg}: {src} (.py) -> {dst}")
|
||||
except Exception as e:
|
||||
print(f"[export] ERROR: 拷贝 {pkg} 源码失败: {e}")
|
||||
return copied
|
||||
|
||||
|
||||
def _copy_c_extensions() -> list[str]:
|
||||
"""从 Python 安装目录拷贝 C 扩展 .pyd 到 plugins/。
|
||||
|
||||
C 扩展(如 _elementtree.pyd)是顶层模块,不在 stdlib 包目录里。
|
||||
Windows 下 .pyd 可能在 py_root/ 或 py_root/DLLs/,都查一遍。
|
||||
"""
|
||||
copied: list[str] = []
|
||||
py_root = Path(sys.base_prefix)
|
||||
search_dirs = [py_root, py_root / "DLLs"]
|
||||
|
||||
for stdlib_pkg, ext_names in _manifest.COMPANION_C_EXTENSIONS.items():
|
||||
for ext_name in ext_names:
|
||||
candidates: list[Path] = []
|
||||
for d in search_dirs:
|
||||
candidates = list(d.glob(f"{ext_name}.*.pyd"))
|
||||
if candidates:
|
||||
break
|
||||
candidates = list(d.glob(f"{ext_name}*.pyd"))
|
||||
if candidates:
|
||||
break
|
||||
if not candidates:
|
||||
print(f"[export] WARN: 找不到 C 扩展 {ext_name}.pyd"
|
||||
f"(已查 {', '.join(str(d) for d in search_dirs)})")
|
||||
continue
|
||||
|
||||
dst = PLUGINS_DIR / candidates[0].name
|
||||
shutil.copy2(candidates[0], dst)
|
||||
copied.append(ext_name)
|
||||
print(f"[export] C 扩展 {ext_name}: {candidates[0]} -> {dst}")
|
||||
|
||||
return copied
|
||||
|
||||
|
||||
def _read_plugin_meta() -> tuple[str, str, str]:
|
||||
"""从 mil/__init__.py 源码提取插件名/版本/描述(绕过 PySide6 import)。
|
||||
|
||||
Returns:
|
||||
(plugin_name, plugin_version, plugin_description)
|
||||
"""
|
||||
import re
|
||||
|
||||
src = (ROOT / "mil" / "__init__.py").read_text(encoding="utf-8")
|
||||
name = re.search(r'NMAE\s*=\s*"([^"]+)"', src).group(1)
|
||||
major = re.search(r"__MAJOR_VER:\s*int\s*=\s*(\d+)", src).group(1)
|
||||
minor = re.search(r"__MINOR_VER:\s*int\s*=\s*(\d+)", src).group(1)
|
||||
micro = re.search(r"__MICRO_VER:\s*int\s*=\s*(\d+)", src).group(1)
|
||||
desc_match = re.search(r'DESCRIPITION\s*=\s*"""([\s\S]*?)"""', src)
|
||||
desc = desc_match.group(1).strip() if desc_match else ""
|
||||
return name, f"{major}.{minor}.{micro}", desc
|
||||
|
||||
|
||||
def _write_manifest(copied_packages: list[str], stdlib_packages: list[str],
|
||||
c_extensions: list[str]) -> None:
|
||||
"""生成 plugins/manifest.json,含插件元数据供远程 zip 扫描读取。"""
|
||||
name, version, desc = _read_plugin_meta()
|
||||
manifest = {
|
||||
"python_tag": _python_tag(),
|
||||
"python_version": platform.python_version(),
|
||||
"packages": copied_packages,
|
||||
"stdlib": stdlib_packages,
|
||||
"c_extensions": c_extensions,
|
||||
"plugin_name": name,
|
||||
"plugin_version": version,
|
||||
"plugin_description": desc,
|
||||
# 主业务包名固定为 mil,用作 zip 文件名前缀与本地子目录名
|
||||
"tool_name": "mil",
|
||||
}
|
||||
dst = PLUGINS_DIR / "manifest.json"
|
||||
dst.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
print(f"[export] manifest -> {dst}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not BUILD_DIR.exists():
|
||||
print("[export] ERROR: build/ 不存在,请先运行 python build_pyd.py", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if PLUGINS_DIR.exists():
|
||||
shutil.rmtree(PLUGINS_DIR)
|
||||
PLUGINS_DIR.mkdir(parents=True)
|
||||
|
||||
copied_pyd = _copy_pyd_files()
|
||||
# openpyxl/et_xmlfile/xml.etree 已编进 mil.pyd,不需要单独拷。
|
||||
# 只需拷 C 扩展(_elementtree.pyd, pyexpat.pyd),运行时走标准 import 加载。
|
||||
copied_cext = _copy_c_extensions()
|
||||
_write_manifest(copied_pyd, [], copied_cext)
|
||||
|
||||
print(f"\n[export] 完成!plugins/ 目录已就绪:{PLUGINS_DIR}")
|
||||
print(f"[export] .pyd 文件: {len(copied_pyd)} 个(含已编入的 openpyxl/et_xmlfile/xml.etree)")
|
||||
print(f"[export] C 扩展: {len(copied_cext)} 个(运行时从插件目录加载)")
|
||||
print(f"[export] Python 版本: {platform.python_version()}")
|
||||
print(f"[export] 下一步: 把 plugins/ 目录放到宿主 exe 旁边")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,71 +0,0 @@
|
||||
"""把 export_runtime.py 产物打包成带版本号的 zip 分发包。
|
||||
|
||||
产物:dist/<tool_name>-<version>-<python_tag>.zip
|
||||
zip 内部扁平结构:解压到 plugins/<tool_name>/ 即得完整目录。
|
||||
|
||||
用法:
|
||||
python build_pyd.py --clean
|
||||
python tools/export_runtime.py
|
||||
python tools/pack_zip.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PLUGINS_DIR = ROOT / "plugins"
|
||||
DIST_DIR = ROOT / "dist"
|
||||
|
||||
|
||||
def _read_plugin_meta() -> tuple[str, str]:
|
||||
"""从 mil/__init__.py 源码提取插件名与版本号(绕过 PySide6 import)。"""
|
||||
src = (ROOT / "mil" / "__init__.py").read_text(encoding="utf-8")
|
||||
name = re.search(r'NMAE\s*=\s*"([^"]+)"', src).group(1)
|
||||
major = re.search(r"__MAJOR_VER:\s*int\s*=\s*(\d+)", src).group(1)
|
||||
minor = re.search(r"__MINOR_VER:\s*int\s*=\s*(\d+)", src).group(1)
|
||||
micro = re.search(r"__MICRO_VER:\s*int\s*=\s*(\d+)", src).group(1)
|
||||
return name, f"{major}.{minor}.{micro}"
|
||||
|
||||
|
||||
def _python_tag() -> str:
|
||||
impl = "cp" + str(sys.version_info.major) + str(sys.version_info.minor)
|
||||
plat = "win_amd64" if platform.system() == "Windows" else platform.machine()
|
||||
return f"{impl}-{plat}"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not PLUGINS_DIR.exists() or not any(PLUGINS_DIR.iterdir()):
|
||||
print("[pack] ERROR: plugins/ 不存在或为空,请先运行 export_runtime.py",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
name, version = _read_plugin_meta()
|
||||
manifest = json.loads((PLUGINS_DIR / "manifest.json").read_text(encoding="utf-8"))
|
||||
# 主业务包名由 export_runtime 写入 manifest,固定为 mil
|
||||
tool_name = manifest["tool_name"]
|
||||
|
||||
zip_name = f"{tool_name}-{version}-{_python_tag()}.zip"
|
||||
DIST_DIR.mkdir(parents=True, exist_ok=True)
|
||||
zip_path = DIST_DIR / zip_name
|
||||
|
||||
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for f in PLUGINS_DIR.rglob("*"):
|
||||
if f.is_file():
|
||||
# 扁平结构:arcname 相对 plugins/,解压到子目录即得完整布局
|
||||
arcname = f.relative_to(PLUGINS_DIR).as_posix()
|
||||
zf.write(f, arcname)
|
||||
|
||||
file_count = len(zipfile.ZipFile(zip_path).namelist())
|
||||
print(f"[pack] 打包完成: {zip_path}")
|
||||
print(f"[pack] 插件: {name} v{version}")
|
||||
print(f"[pack] 文件数: {file_count}")
|
||||
print(f"[pack] 下一步: 把 zip 复制到远程 Y:/SE/xufeifei/plugins/")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user