清理无用代码、合并工具链、修复注释与日志问题

- 删除破损文件: 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:
2026-07-21 18:14:34 +08:00
parent 91bb61444e
commit f188055228
20 changed files with 305 additions and 936 deletions
+7 -105
View File
@@ -28,8 +28,6 @@ share/python-wheels/
MANIFEST MANIFEST
# PyInstaller # 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 *.manifest
*.spec *.spec
@@ -52,82 +50,6 @@ coverage.xml
.pytest_cache/ .pytest_cache/
cover/ 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 # Environments
.env .env
.venv .venv
@@ -137,40 +59,20 @@ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy # mypy
.mypy_cache/ .mypy_cache/
.dmypy.json .dmypy.json
dmypy.json dmypy.json
# Pyre type checker # Ruff
.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_cache/ .ruff_cache/
# UV
.pdm.toml
.pdm-python
.pdm-build/
__pypackages__/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
*.xlsx *.xlsx
+273 -80
View File
@@ -1,8 +1,8 @@
"""通过 Nuitka 把 mil 包及其纯 Python 伴生包编译为 .pyd。 """通过 Nuitka 把 mil 包及其纯 Python 伴生包编译为 .pyd,并完成导出、打包、部署、验证全流程
策略说明: 策略说明:
- **mil 业务包**mil/ 整个包:core + ui)→ 编 pyd - **mil 业务包**mil/ 整个包:core + ui)→ 编 pyd
- **Qt 桥接代码**mil/ui/*_ui.py、main_ui.py)→ 保留为源码; - **Qt 桥接代码**mil/ui/*_ui.py)→ 保留为源码;
- **纯 Python 伴生包**COMPANIONS 列表)→ 逐个编 pyd,产物随 mil 一起分发; - **纯 Python 伴生包**COMPANIONS 列表)→ 逐个编 pyd,产物随 mil 一起分发;
- **Qt .ui 资源文件** → 运行时由 mil 包内的 uic.loadUiType 直接读源码路径; - **Qt .ui 资源文件** → 运行时由 mil 包内的 uic.loadUiType 直接读源码路径;
- **PySide6** → --nofollow-import-to,运行时走宿主 pip 安装的二进制。 - **PySide6** → --nofollow-import-to,运行时走宿主 pip 安装的二进制。
@@ -19,47 +19,91 @@
python build_pyd.py --no-progress # 关闭进度条(CI 友好) python build_pyd.py --no-progress # 关闭进度条(CI 友好)
python build_pyd.py --only mil # 只编指定包(逗号分隔) python build_pyd.py --only mil # 只编指定包(逗号分隔)
python build_pyd.py --no-deploy # 不复制 zip 到网络盘 python build_pyd.py --no-deploy # 不复制 zip 到网络盘
python build_pyd.py --verify # 单独验证伴生包加载链路
python build_pyd.py --verify --fallback # 验证时允许回退到 site-packages
全流程四步: 全流程四步:
1. Nuitka 编译 .pyd → build/ 1. Nuitka 编译 .pyd → build/
2. export_runtime 聚合 → plugins/ 2. export 聚合 → plugins/
3. pack_zip 打包 → dist/<tool>-<ver>-<tag>.zip 3. pack 打包 → dist/<tool>-<ver>-<tag>.zip
4. 复制 zip → Y:/SE/xufeifei/plugins/--no-deploy 跳过) 4. 复制 zip → Y:/SE/xufeifei/plugins/--no-deploy 跳过)
产物布局: 产物布局:
build/ build/
mil.cp311-win_amd64.pyd ← 业务包 mil.cp311-win_amd64.pyd ← 业务包
plugins/ ← export_runtime.py 生成(中间产物 plugins/ ← 中间产物
dist/ dist/
mil-<version>-cp311-win_amd64.zip ← pack_zip.py 生成(最终交付物 mil-<version>-cp311-win_amd64.zip ← 最终交付物
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import importlib.util
import json
import platform
import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tempfile
import zipfile
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
BUILD_DIR = ROOT / "build" BUILD_DIR = ROOT / "build"
PLUGINS_DIR = ROOT / "plugins"
DIST_DIR = ROOT / "dist" DIST_DIR = ROOT / "dist"
REMOTE_DEPLOY_DIR = Path("Y:/SE/xufeifei/plugins") 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: def _resolve_entry(pkg: str) -> Path:
"""定位包的入口 __init__.py。 """定位包的入口 __init__.py。
业务包(mil)从工程根找;第三方包从当前 Python 的 site-packages 找。 业务包(mil)从工程根找;第三方包从当前 Python 的 site-packages 找。
""" """
# 1) 业务包:从工程根找(mil 与 build_pyd.py 同级)
local = ROOT / pkg / "__init__.py" local = ROOT / pkg / "__init__.py"
if local.is_file(): if local.is_file():
return local return local
# 2) 第三方包:从当前解释器的 site-packages 找
try: try:
import importlib.util
spec = importlib.util.find_spec(pkg) spec = importlib.util.find_spec(pkg)
if spec is not None and spec.origin: if spec is not None and spec.origin:
origin = Path(spec.origin) origin = Path(spec.origin)
@@ -70,49 +114,31 @@ def _resolve_entry(pkg: str) -> Path:
raise FileNotFoundError(f"找不到包 {pkg} 的入口 __init__.py") 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: def build_one(pkg: str, no_progress: bool) -> Path:
"""对单个包跑 Nuitka --module,产出 <pkg>.<python-tag>.pyd。 """对单个包跑 Nuitka --module,产出 <pkg>.<python-tag>.pyd。
区分业务包与第三方伴生包:
- 业务包(mil):保留原有逻辑,让 Nuitka 跟进所有依赖;
- 第三方包:显式排除 stdlib 子模块,避免"半截包"问题
(--module 模式物理上编不进 C 加速模块,会留下残缺包导致运行期 ImportError)。
关键约束:--module 模式下要编""必须传"包目录路径"(末尾带 /), 关键约束:--module 模式下要编""必须传"包目录路径"(末尾带 /),
不能传 __init__.py。Nuitka 看到目录就会自动按包模式处理。 不能传 __init__.py。Nuitka 看到目录就会自动按包模式处理。
""" """
entry = _resolve_entry(pkg) # 入口解析保留,仅用于校验包存在 entry = _resolve_entry(pkg)
pkg_dir = entry.parent pkg_dir = entry.parent
pkg_name = pkg_dir.name 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) + "/" module_arg = str(pkg_dir) + "/"
cmd: list[str] = [ cmd: list[str] = [
sys.executable, "-m", "nuitka", sys.executable, "-m", "nuitka",
"--module", "--module",
module_arg, # ← 包目录,不是 __init__.py module_arg,
f"--include-package={pkg_name}", f"--include-package={pkg_name}",
f"--output-dir={BUILD_DIR}", # 绝对路径,避免被 cwd 影响 f"--output-dir={BUILD_DIR}",
] ]
# 仅对第三方包显式排除 stdlib 子模块,避免半截包问题。
# 运行时这些 stdlib 走宿主 Python 自带版本(任何合规安装都有)。
if not is_local: if not is_local:
cmd += [ 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",
"--nofollow-import-to=xml.etree.ElementTree", "--nofollow-import-to=xml.etree.ElementTree",
"--nofollow-import-to=zipfile", "--nofollow-import-to=zipfile",
@@ -125,39 +151,22 @@ def build_one(pkg: str, no_progress: bool) -> Path:
"--nofollow-import-to=logging", "--nofollow-import-to=logging",
] ]
# 对业务包(mil):把纯 Python 依赖编进 mil.pyd,C 扩展留给运行时加载。
#
# 原理:openpyxl/et_xmlfile/xml.etree 都是纯 PythonNuitka --module 会把
# 它们的 .py 代码编进 mil.pyd,运行时 mil.pyd 内部 import 走 hard-import
# 不查 sys.path。只有 _elementtree/pyexpat 是 C 扩展,--module 编不进去,
# 运行时走标准 import 从 plugins/mil/ 加载。
#
# 好处:宿主 exe 不需要预知插件依赖哪些 stdlib,真正一次编译永久适用。
# 新增纯 Python stdlib 依赖时 Nuitka 自动编进 mil.pyd,不需要重编宿主。
if is_local: if is_local:
# 从 manifest 读取要编进 mil.pyd 的纯 Python 包
for pkg in _mod.COMPANION_PACKAGES_INLINE: for pkg in _mod.COMPANION_PACKAGES_INLINE:
cmd.append(f"--include-package={pkg}") cmd.append(f"--include-package={pkg}")
# Nuitka 默认不编译 stdlib,显式 include openpyxl 间接依赖的 stdlib 子模块
for mod in _mod.COMPANION_STDLIB_INLINE: for mod in _mod.COMPANION_STDLIB_INLINE:
cmd.append(f"--include-module={mod}") cmd.append(f"--include-module={mod}")
# C 扩展不编进 .pyd,运行时从插件目录加载
for ext_pkgs in _mod.COMPANION_C_EXTENSIONS.values(): for ext_pkgs in _mod.COMPANION_C_EXTENSIONS.values():
for ext in ext_pkgs: for ext in ext_pkgs:
cmd.append(f"--nofollow-import-to={ext}") cmd.append(f"--nofollow-import-to={ext}")
# pyexpat 是 _elementtree 的底层 C 扩展,也需 nofollow
cmd.append("--nofollow-import-to=pyexpat") cmd.append("--nofollow-import-to=pyexpat")
cmd += ["--no-deployment-flag=frame-useless-set-trace"] cmd += ["--no-deployment-flag=frame-useless-set-trace"]
if no_progress: if no_progress:
cmd.append("--no-progress") cmd.append("--no-progress")
# cwd 切到包所在目录的父目录:Nuitka 在此目录下识别包名
subprocess.run(cmd, cwd=str(pkg_dir.parent), check=True) 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")) candidates = list(BUILD_DIR.glob(f"{pkg_name}.*.pyd"))
if not candidates: if not candidates:
candidates = list((BUILD_DIR / pkg_name).glob(f"{pkg_name}.*.pyd")) 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] return candidates[0]
def _export_and_pack() -> None: # ============================================================
"""编译完成后,串调 export_runtime + pack_zip 生成最终 zip。 # 导出 plugins/ 分发包(原 tools/export_runtime.py
# ============================================================
两个工具脚本都暴露 main(),直接 import 调用,避免 subprocess 开销与 def _copy_pyd_files() -> list[str]:
重复的 importlib 加载 manifest 逻辑。子脚本失败时抛 SystemExit, """把 build/ 下的 .pyd 拷到 plugins/,返回已拷贝的包名列表。"""
此处捕获并提前返回(后续步骤无意义)。 copied: list[str] = []
""" for pkg in _mod.COMPANION_PACKAGES + ["mil"]:
tools_dir = ROOT / "tools" matches = list(BUILD_DIR.glob(f"{pkg}.*.pyd"))
if str(tools_dir) not in sys.path: if not matches:
sys.path.insert(0, str(tools_dir)) matches = list((BUILD_DIR / pkg).glob(f"{pkg}.*.pyd"))
if not matches:
print(f"[export] WARN: build/ 下未找到 {pkg}.*.pyd,跳过")
continue
try: dst = PLUGINS_DIR / matches[0].name
print("[build_pyd] 步骤 2/4: 导出 plugins/ 分发包") shutil.copy2(matches[0], dst)
import export_runtime copied.append(pkg)
export_runtime.main() print(f"[export] {pkg}: {matches[0].name} -> {dst}")
print("[build_pyd] 步骤 3/4: 打包 zip") return copied
import pack_zip
pack_zip.main()
except SystemExit as e:
# 子脚本 sys.exit(1) 表示前置条件不满足(如 build/ 为空),直接中止
if e.code != 0:
print(f"[build_pyd] 打包流程中止(退出码 {e.code}", file=sys.stderr)
return False
return True
def _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: def _deploy_zip() -> None:
"""把 dist/ 下最新生成的 zip 复制到网络盘远程分发目录。""" """把 dist/ 下最新生成的 zip 复制到网络盘远程分发目录。"""
zips = sorted(DIST_DIR.glob("*.zip"), key=lambda f: f.stat().st_mtime, reverse=True) 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}") 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: def main() -> None:
"""编译 mil 包及其伴生依赖为 .pyd,导出为 plugins/ 目录,打包为 zip 并部署至网络盘。
通过 --verify 可跳过编译,仅验证已编译产物的 openpyxl 读写闭环。
"""
parser = argparse.ArgumentParser(description="编译 mil + 伴生包为 .pyd,并打包成 zip") parser = argparse.ArgumentParser(description="编译 mil + 伴生包为 .pyd,并打包成 zip")
parser.add_argument("--clean", action="store_true", help="清理 build/ 与上次产物") parser.add_argument("--clean", action="store_true", help="清理 build/ 与上次产物")
parser.add_argument("--no-progress", action="store_true", help="关闭进度条(CI 友好)") parser.add_argument("--no-progress", action="store_true", help="关闭进度条(CI 友好)")
parser.add_argument("--no-deploy", action="store_true", help="不复制 zip 到网络盘") parser.add_argument("--no-deploy", action="store_true", help="不复制 zip 到网络盘")
parser.add_argument( parser.add_argument("--only", type=str, default="",
"--only", help="只编指定包(逗号分隔),如 --only mil,openpyxl;默认全编")
type=str, parser.add_argument("--verify", action="store_true", help="验证伴生包 .pyd 加载链路(不编译)")
default="", parser.add_argument("--fallback", action="store_true",
help="只编指定包(逗号分隔),如 --only mil,openpyxl;默认全编", help="验证时,未发现产物则回退到 site-packages(开发自测用)")
)
args = parser.parse_args() args = parser.parse_args()
if args.verify:
sys.exit(verify_companions(args.fallback))
if args.clean and BUILD_DIR.exists(): if args.clean and BUILD_DIR.exists():
shutil.rmtree(BUILD_DIR) shutil.rmtree(BUILD_DIR)
@@ -239,10 +422,20 @@ def main() -> None:
out = build_one(pkg, args.no_progress) out = build_one(pkg, args.no_progress)
print(f"[build_pyd] {pkg} -> {out}") print(f"[build_pyd] {pkg} -> {out}")
ok = _export_and_pack() try:
if ok and not args.no_deploy: 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() _deploy_zip()
if __name__ == "__main__": if __name__ == "__main__":
main() main()
-19
View File
@@ -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())
-52
View File
@@ -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
View File
@@ -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
View File
@@ -9,13 +9,13 @@ NMAE = "MIL Tool"
# 工具版本 # 工具版本
__MAJOR_VER: int = 0 # 主版本号 __MAJOR_VER: int = 0 # 主版本号
__MINOR_VER: int = 0 # 次版本号 __MINOR_VER: int = 0 # 次版本号
__MICRO_VER: int = 4 # 修订版本号 __MICRO_VER: int = 6 # 修订版本号
VERSION:str = f"{__MAJOR_VER}.{__MINOR_VER}.{__MICRO_VER}" VERSION:str = f"{__MAJOR_VER}.{__MINOR_VER}.{__MICRO_VER}"
# 工具描述 # 工具描述
DESCRIPITION = """ DESCRIPITION = """
主要用于生成Simlink Test Case。 主要用于生成Simulink Test Case。
""" """
SVG = """ SVG = """
+1 -1
View File
@@ -3,7 +3,7 @@
提供 MIL 仿真数据读取功能 提供 MIL 仿真数据读取功能
使用示例: 使用示例:
from src.core import read_excel_data from mil.core import read_excel_data
result = read_excel_data("simulation.xlsx") result = read_excel_data("simulation.xlsx")
""" """
+7 -25
View File
@@ -16,7 +16,8 @@ class Config():
配置传输载体。序列化使用 JSON 文件持久化。 配置传输载体。序列化使用 JSON 文件持久化。
Attributes: Attributes:
DataPath: 仿真数据目录(Excel 原始文件所在路径 path: 配置文件路径(JSON 文件
DataPath: 仿真数据目录
FilePath: 当前打开的 Excel 文件路径 FilePath: 当前打开的 Excel 文件路径
AddTimeEn: 用例步骤时间是否按累加方式记录 AddTimeEn: 用例步骤时间是否按累加方式记录
GeratePath: 是否为生成的用例另存新文件 GeratePath: 是否为生成的用例另存新文件
@@ -47,16 +48,13 @@ class Config():
} }
def load_config(self) -> "Config": def load_config(self) -> "Config":
"""从 JSON 文件读取配置并填充到当前实例的各个字段。 """ self.path 指定的 JSON 文件读取配置并填充到当前实例的各个字段。
解析规则: 解析规则:
- JSON 中存在的字段会被回写到 Config 的对应字段; - JSON 中存在的字段会被回写到 Config 的对应字段;
- JSON 中缺失的字段保持当前 Config 实例的默认值; - JSON 中缺失的字段保持当前 Config 实例的默认值;
- JSON 中多余字段被忽略(向前兼容:增加字段不会破坏老配置)。 - JSON 中多余字段被忽略(向前兼容:增加字段不会破坏老配置)。
Args:
config_path: 配置文件路径。
Returns: Returns:
self:填充后的 Config 实例,便于链式调用。 self:填充后的 Config 实例,便于链式调用。
@@ -85,10 +83,7 @@ class Config():
return self return self
def save_config(self): def save_config(self):
"""将当前配置以 JSON 格式写入磁盘 """将当前配置以 JSON 格式写入 self.path 指定的文件
Args:
config_path: 配置文件路径。
Raises: Raises:
Exception: 写入失败时记录日志并原样抛出异常。 Exception: 写入失败时记录日志并原样抛出异常。
@@ -97,7 +92,8 @@ class Config():
with open(self.path,'w',encoding='utf-8') as f: with open(self.path,'w',encoding='utf-8') as f:
json.dump(self.to_dict(), f, indent=4, ensure_ascii=False) json.dump(self.to_dict(), f, indent=4, ensure_ascii=False)
except Exception as e: except Exception as e:
logger.error(f"配置文件{self.path}写入失败{e.args}") logger.error(f"配置文件{self.path}写入失败: {e}")
raise
@dataclass @dataclass
class DataLog: class DataLog:
@@ -105,7 +101,7 @@ class DataLog:
Attributes: Attributes:
time: 时间戳(秒) time: 时间戳(秒)
value: 信号值(可以是任意类型) value: 信号值(字符串形式,写入 Excel 时按内容推断 int/str 类型)
""" """
time: float = 0.0 time: float = 0.0
value: str = "" value: str = ""
@@ -124,7 +120,6 @@ class SignalData:
attributes: 列上方各属性行(行号 -> 文本)的字典映射 attributes: 列上方各属性行(行号 -> 文本)的字典映射
datalog: 时间戳-数值采样点列表 datalog: 时间戳-数值采样点列表
""" """
# signal_type: str | None = None
column: int = 0 column: int = 0
attributes: dict[str, str] = field(default_factory=dict) attributes: dict[str, str] = field(default_factory=dict)
datalog: list[DataLog] = field(default_factory=list) datalog: list[DataLog] = field(default_factory=list)
@@ -177,16 +172,3 @@ class ExcelDataResult:
""" """
return list(self.signals.keys()) 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
+1 -3
View File
@@ -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: 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")) candidates = list(build_dir.glob(f"{pkg}.*.pyd"))
if not candidates: if not candidates:
return None return None
@@ -87,7 +87,6 @@ def load_companions(packages: list[str]) -> list[str]:
loaded: list[str] = [] loaded: list[str] = []
for pkg in packages: for pkg in packages:
if pkg in sys.modules: if pkg in sys.modules:
logger.debug(f"伴生包 {pkg} 已在 sys.modules,跳过")
loaded.append(pkg) loaded.append(pkg)
continue continue
@@ -102,5 +101,4 @@ def load_companions(packages: list[str]) -> list[str]:
logger.warning( logger.warning(
f"伴生包未找到: {pkg}runtime={runtime_dir} / build={build_dir} 均无)" f"伴生包未找到: {pkg}runtime={runtime_dir} / build={build_dir} 均无)"
) )
return loaded return loaded
+1 -1
View File
@@ -3,7 +3,7 @@
这是唯一的事实源: 这是唯一的事实源:
- build_pyd.py 读 COMPANION_PACKAGES_PYD 决定要编哪些伴生包; - build_pyd.py 读 COMPANION_PACKAGES_PYD 决定要编哪些伴生包;
- build_pyd.py 编译 mil 时用 --include-package 把 COMPANION_PACKAGES_INLINE 编进 mil.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 扩展。
加新伴生包时只动这里,其它脚本自动跟随。 加新伴生包时只动这里,其它脚本自动跟随。
""" """
+1 -2
View File
@@ -133,10 +133,9 @@ def _analysis_case(data_dict: dict, sheets_dict: dict) -> CaseResultDict:
for case in sheet_dict.keys(): for case in sheet_dict.keys():
try: try:
if sheet_dict[case]["enable"]: if sheet_dict[case]["enable"]:
# 已完成的用例跳过生成
continue continue
except KeyError: except KeyError:
# 缺少 enable 字段:保守跳过 logger.warning(f"用例 '{case}' 缺少 enable 字段,跳过生成")
continue continue
# 拷贝数据并重置 datalog,然后按步骤逐条填充 # 拷贝数据并重置 datalog,然后按步骤逐条填充
-3
View File
@@ -86,19 +86,16 @@ def read_excel_case(
except FileNotFoundError: except FileNotFoundError:
raise ExcelReadError(f"Excel文件 {excel_path} 不存在") raise ExcelReadError(f"Excel文件 {excel_path} 不存在")
except Exception as e: except Exception as e:
logger.error(f"读取 Excel 文件失败: {e}")
raise ExcelReadError(f"无法读取Excel文件 {excel_path}: {str(e)}") raise ExcelReadError(f"无法读取Excel文件 {excel_path}: {str(e)}")
# 版本表 Atech-Hefei 必须存在;解析后将其从工作簿移除,避免进入后续遍历 # 版本表 Atech-Hefei 必须存在;解析后将其从工作簿移除,避免进入后续遍历
try: try:
version = _get_template_version(wb["Atech-Hefei"]) version = _get_template_version(wb["Atech-Hefei"])
if version is None: if version is None:
logger.error("缺少模板版本号")
raise ExcelFormatError("Excel格式错误,缺少模板版本号") raise ExcelFormatError("Excel格式错误,缺少模板版本号")
logger.info(f"用例模板版本号: {version}") logger.info(f"用例模板版本号: {version}")
del wb["Atech-Hefei"] del wb["Atech-Hefei"]
except KeyError: except KeyError:
logger.error(f"缺少 'Atech-Hefei'")
raise ExcelFormatError(f"Excel文件 {excel_path} 格式错误,缺少 Atech-Hefei 表") raise ExcelFormatError(f"Excel文件 {excel_path} 格式错误,缺少 Atech-Hefei 表")
result_dict: CaseDict = {} result_dict: CaseDict = {}
+1 -14
View File
@@ -32,23 +32,14 @@ class ExcelReaderConfig:
source_header: 数据源标记行文本,默认 "Source: Input" source_header: 数据源标记行文本,默认 "Source: Input"
time_column: 时间列索引,默认 1(A 列) time_column: 时间列索引,默认 1(A 列)
header_row: 信号名称所在行号,默认 1 header_row: 信号名称所在行号,默认 1
type_row: 信号类型所在行号,默认 3
interp_row: 插值策略所在行号,默认 6
data_start_row_offset: 相对于 source_row 的数据起始行偏移量,默认 1 data_start_row_offset: 相对于 source_row 的数据起始行偏移量,默认 1
output_header: 输出数据标记行文本,默认 "Source: Output"
block_path_row: BlockPath 属性所在行号,默认 4
""" """
sheet_name: str = "Scenario1" sheet_name: str = "Scenario1"
source_header: str = "Source: Input" source_header: str = "Source: Input"
time_column: int = 1 time_column: int = 1
header_row: int = 1 header_row: int = 1
type_row: int = 3
interp_row: int = 6
data_start_row_offset: int = 1 data_start_row_offset: int = 1
output_header: str = "Source: Output"
block_path_row: int = 4
def read_excel_data( def read_excel_data(
excel_path: str, excel_path: str,
@@ -87,17 +78,14 @@ def read_excel_data(
wb = load_workbook(excel_path) wb = load_workbook(excel_path)
logger.info(f"文件加载成功,Sheet 数量: {len(wb.sheetnames)}") logger.info(f"文件加载成功,Sheet 数量: {len(wb.sheetnames)}")
except FileNotFoundError: except FileNotFoundError:
logger.error(f"文件不存在: {excel_path}")
raise ExcelReadError(f"Excel文件 {excel_path} 不存在") raise ExcelReadError(f"Excel文件 {excel_path} 不存在")
except Exception as e: except Exception as e:
logger.error(f"读取文件失败: {e}")
raise ExcelReadError(f"读取Excel文件 {excel_path} 失败: {e}") raise ExcelReadError(f"读取Excel文件 {excel_path} 失败: {e}")
# 2) 取出约定名称的工作表,KeyError 表示工作表缺失,视为格式错误 # 2) 取出约定名称的工作表,KeyError 表示工作表缺失,视为格式错误
try: try:
sheet = wb[config.sheet_name] sheet = wb[config.sheet_name]
except KeyError: except KeyError:
logger.error(f"缺少 '{config.sheet_name}'")
raise ExcelFormatError(f"Excel文件 {excel_path} 缺少 '{config.sheet_name}'") raise ExcelFormatError(f"Excel文件 {excel_path} 缺少 '{config.sheet_name}'")
# 3) 自第 1 行起纵向扫描 "Source: Input" 标记行 # 3) 自第 1 行起纵向扫描 "Source: Input" 标记行
@@ -108,7 +96,6 @@ def read_excel_data(
source_row += 1 source_row += 1
# 扫描到表格末尾仍未命中,认定为格式错误 # 扫描到表格末尾仍未命中,认定为格式错误
if source_row >= sheet.max_row: if source_row >= sheet.max_row:
logger.error(f"缺少 {config.source_header} 标记")
raise ExcelFormatError(f"Excel格式错误,缺少 {config.source_header}") raise ExcelFormatError(f"Excel格式错误,缺少 {config.source_header}")
logger.debug(f"找到 {config.source_header} 标记,行号: {source_row}") logger.debug(f"找到 {config.source_header} 标记,行号: {source_row}")
@@ -177,7 +164,7 @@ def __get_signal_attributes(
Args: Args:
sheet: Worksheet 对象 sheet: Worksheet 对象
column: 列号 column: 列号
max_row: 信号名行(不含)以内的最大行号 max_row: Source: Input 标记行(不含),即属性区域的上界
Returns: Returns:
信号属性字典,键为行号(int),值为该单元格文本(str) 信号属性字典,键为行号(int),值为该单元格文本(str)
-1
View File
@@ -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') new_source_row = new_data.pop('source_row')
if not isinstance(old_wb, Workbook) or not isinstance(old_sheet, Worksheet): if not isinstance(old_wb, Workbook) or not isinstance(old_sheet, Worksheet):
logger.error("数据类型错误,Workbook 或 Worksheet 类型不匹配")
raise CaseDataError("数据异常,Workbook 或 Worksheet 类型错误") raise CaseDataError("数据异常,Workbook 或 Worksheet 类型错误")
# 计算"新增"与"删除"两组信号 # 计算"新增"与"删除"两组信号
+2
View File
@@ -106,6 +106,7 @@ class MILProject(QFrame, Ui_FrameMILProject):
else: else:
path,type = QFileDialog.getOpenFileName(self, "选择文件", "./","Excel工作簿(*.xlsx)") path,type = QFileDialog.getOpenFileName(self, "选择文件", "./","Excel工作簿(*.xlsx)")
if not os.path.exists(path): if not os.path.exists(path):
logger.debug(f"数据文件选择已取消: {dataPath}")
return return
self.lineEditDataPath.setText(path) self.lineEditDataPath.setText(path)
self.on_datapath_editing_finished_event() self.on_datapath_editing_finished_event()
@@ -117,6 +118,7 @@ class MILProject(QFrame, Ui_FrameMILProject):
else: else:
path,type = QFileDialog.getOpenFileName(self, "选择文件", "./","Excel工作簿(*.xlsx)") path,type = QFileDialog.getOpenFileName(self, "选择文件", "./","Excel工作簿(*.xlsx)")
if not os.path.exists(path): if not os.path.exists(path):
logger.debug(f"测试用例文件选择已取消: {filePath}")
return return
self.lineEditFilePath.setText(path) self.lineEditFilePath.setText(path)
self.on_filepath_editing_finished_event() self.on_filepath_editing_finished_event()
+9 -7
View File
@@ -11,8 +11,8 @@ from .mil_project import MILProject
from mil.core import Config from mil.core import Config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
CONF = "mil.json" CONF = "mil.json"
class FrameMILTool(QFrame,Ui_FrameMILTool): class FrameMILTool(QFrame,Ui_FrameMILTool):
@@ -109,12 +109,11 @@ class FrameMILTool(QFrame,Ui_FrameMILTool):
if text == "" or self.lock_event: if text == "" or self.lock_event:
return return
self.config.CurrProject= text self.config.CurrProject = text
self.config.save_config() self.config.save_config()
self.DataPath.setText(self.config.ItemConfigs[self.config.CurrProject]["DataPath"]) self.DataPath.setText(self.config.ItemConfigs[self.config.CurrProject]["DataPath"])
self.FilePath.setText(self.config.ItemConfigs[self.config.CurrProject]["FilePath"]) self.FilePath.setText(self.config.ItemConfigs[self.config.CurrProject]["FilePath"])
pass
def on_new_pro_event(self): def on_new_pro_event(self):
item = QListWidgetItem() item = QListWidgetItem()
@@ -127,7 +126,6 @@ class FrameMILTool(QFrame,Ui_FrameMILTool):
self.listWidget.addItem(item) self.listWidget.addItem(item)
self.listWidget.setCurrentItem(item) self.listWidget.setCurrentItem(item)
self.listWidget.setItemWidget(item, mil_project) self.listWidget.setItemWidget(item, mil_project)
pass
def add_pro_event(self, name): def add_pro_event(self, name):
item = QListWidgetItem() item = QListWidgetItem()
@@ -143,8 +141,9 @@ class FrameMILTool(QFrame,Ui_FrameMILTool):
def on_del_pro_event(self): def on_del_pro_event(self):
mil_project = self.listWidget.itemWidget(self.listWidget.currentItem()) mil_project = self.listWidget.itemWidget(self.listWidget.currentItem())
if not isinstance(mil_project,MILProject): if not isinstance(mil_project, MILProject):
raise "on_del_pro_event error!!!" logger.error("on_del_pro_event: 当前项不是 MILProject 实例")
return
if self.config.CurrProject == mil_project.name: if self.config.CurrProject == mil_project.name:
self.config.CurrProject = "" self.config.CurrProject = ""
@@ -154,7 +153,6 @@ class FrameMILTool(QFrame,Ui_FrameMILTool):
self.config.save_config() self.config.save_config()
self.update_config() self.update_config()
pass
def search_mil_pro_item(self, name): def search_mil_pro_item(self, name):
for mil_project in self.listWidget.findChildren(MILProject): for mil_project in self.listWidget.findChildren(MILProject):
@@ -171,6 +169,8 @@ class FrameMILTool(QFrame,Ui_FrameMILTool):
else: else:
path,type = QFileDialog.getOpenFileName(self, "选择文件", os.getcwd(),"Excel工作簿(*.xlsx)") path,type = QFileDialog.getOpenFileName(self, "选择文件", os.getcwd(),"Excel工作簿(*.xlsx)")
self.DataPath.setText(path) self.DataPath.setText(path)
if not path:
return
self.config.ItemConfigs[self.config.CurrProject]["DataPath"] = path self.config.ItemConfigs[self.config.CurrProject]["DataPath"] = path
mil_project = self.search_mil_pro_item(self.config.CurrProject) mil_project = self.search_mil_pro_item(self.config.CurrProject)
@@ -185,6 +185,8 @@ class FrameMILTool(QFrame,Ui_FrameMILTool):
else: else:
path,type = QFileDialog.getOpenFileName(self, "选择文件", os.getcwd(),"Excel工作簿(*.xlsx)") path,type = QFileDialog.getOpenFileName(self, "选择文件", os.getcwd(),"Excel工作簿(*.xlsx)")
self.FilePath.setText(path) self.FilePath.setText(path)
if not path:
return
self.config.ItemConfigs[self.config.CurrProject]["FilePath"] = path self.config.ItemConfigs[self.config.CurrProject]["FilePath"] = path
mil_project = self.search_mil_pro_item(self.config.CurrProject) mil_project = self.search_mil_pro_item(self.config.CurrProject)
-283
View File
@@ -1,283 +0,0 @@
"""导出插件分发包到 plugins/ 目录。
把以下内容打包到 plugins/
1. build/ 下的 .pydmil + openpyxl + et_xmlfile
2. 从当前 Python 环境拷贝 stdlib 子包(.py 文件)
3. 从 Python 安装根目录拷贝 C 扩展 .pyd(如 _elementtree.pyd
4. 生成 manifest.json 供 runtime_hook.py 读取
产物 plugins/ 目录就是完整的"插件分发包"——宿主直接放到 exe 旁边即可。
用法:
# 前置:先编译 .pyd
python build_pyd.py --clean
# 导出插件分发包
python tools/export_runtime.py
# 产物:plugins/ 目录
"""
from __future__ import annotations
import json
import platform
import shutil
import sys
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
BUILD_DIR = ROOT / "build"
PLUGINS_DIR = ROOT / "plugins"
# 用 importlib 加载 manifest(绕过 mil/__init__.py 的 PySide6 依赖)
import importlib.util
_spec = importlib.util.spec_from_file_location(
"mil_manifest", ROOT / "mil" / "core" / "manifest.py"
)
_manifest = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_manifest)
def _python_tag() -> str:
"""返回 Python ABI tag,如 cp311-win_amd64。"""
impl = "cp" + str(sys.version_info.major) + str(sys.version_info.minor)
plat = "win_amd64" if platform.system() == "Windows" else platform.machine()
return f"{impl}-{plat}"
def _copy_pyd_files() -> list[str]:
"""把 build/ 下的 .pyd 拷到 plugins/,返回已拷贝的包名列表。"""
copied: list[str] = []
for pkg in _manifest.COMPANION_PACKAGES + ["mil"]:
matches = list(BUILD_DIR.glob(f"{pkg}.*.pyd"))
if not matches:
matches = list((BUILD_DIR / pkg).glob(f"{pkg}.*.pyd"))
if not matches:
print(f"[export] WARN: build/ 下未找到 {pkg}.*.pyd,跳过")
continue
dst = PLUGINS_DIR / matches[0].name
shutil.copy2(matches[0], dst)
copied.append(pkg)
print(f"[export] {pkg}: {matches[0].name} -> {dst}")
return copied
def _find_stdlib_zip() -> Path | None:
"""定位 Python stdlib 的 zip 文件(如 python310.zip)。"""
for p in sys.path:
path = Path(p)
if path.is_file() and path.suffix == ".zip" and "python" in path.name.lower():
return path
return None
def _extract_from_zip(zip_path: Path, pkg_name: str, dst: Path) -> bool:
"""从 zip 文件提取 stdlib 包目录到 plugins/。
Args:
zip_path: python310.zip 路径
pkg_name: 顶层包名(如 "xml"
dst: 目标目录 plugins/xml
Returns:
是否成功提取
"""
try:
with zipfile.ZipFile(zip_path, "r") as zf:
members = [m for m in zf.namelist() if m.startswith(f"{pkg_name}/")]
if not members:
return False
zf.extractall(PLUGINS_DIR, members=members)
print(f"[export] stdlib {pkg_name}: {zip_path} (zip) -> {dst}")
return True
except Exception as e:
print(f"[export] ERROR: 从 zip 提取 {pkg_name} 失败: {e}")
return False
def _copy_stdlib_from_fs(pkg_name: str, src: Path) -> bool:
"""从文件系统拷贝 stdlib 包目录到 plugins/。"""
dst = PLUGINS_DIR / pkg_name
if dst.exists():
shutil.rmtree(dst)
try:
shutil.copytree(src, dst, dirs_exist_ok=False)
print(f"[export] stdlib {pkg_name}: {src} (fs) -> {dst}")
return True
except Exception as e:
print(f"[export] ERROR: 拷贝 {pkg_name} 失败: {e}")
return False
def _copy_stdlib_packages() -> list[str]:
"""把 manifest 声明的 stdlib 子包从当前 Python 环境拷到 plugins/。
支持两种 stdlib 存储形态:
1. 文件系统目录(<python>/Lib/xml/)→ shutil.copytree
2. zip 压缩包(python310.zip/xml/)→ zipfile 提取
"""
copied: list[str] = []
seen: set[str] = set()
stdlib_zip = _find_stdlib_zip()
for module_name in _manifest.all_stdlib_modules():
top = module_name.split(".")[0]
if top in seen:
continue
seen.add(top)
dst = PLUGINS_DIR / top
if dst.exists():
shutil.rmtree(dst)
# 优先从文件系统找
try:
import importlib.util
spec = importlib.util.find_spec(top)
if spec and spec.submodule_search_locations:
src = Path(spec.submodule_search_locations[0])
if src.is_dir() and ".zip" not in str(src):
if _copy_stdlib_from_fs(top, src):
copied.append(top)
continue
except (ImportError, ValueError):
pass
# 从 zip 提取
if stdlib_zip:
if _extract_from_zip(stdlib_zip, top, dst):
copied.append(top)
continue
print(f"[export] WARN: 找不到 stdlib 包 {top}fs 和 zip 均无)")
return copied
def _copy_py_packages() -> list[str]:
"""把 COMPANION_PACKAGES_PY 声明的包从 site-packages 拷 .py 源码到 plugins/。
这些包不编译 .pyd,保留 .py 源码形式,import 走标准 importlib 机制,
能正确从 sys.path 找到插件自带的 stdlib xml。
"""
copied: list[str] = []
for pkg in _manifest.COMPANION_PACKAGES_PY:
try:
import importlib.util
spec = importlib.util.find_spec(pkg)
if spec is None or not spec.submodule_search_locations:
print(f"[export] WARN: 找不到包 {pkg} 的源码目录")
continue
src = Path(spec.submodule_search_locations[0])
if not src.is_dir():
print(f"[export] WARN: {pkg} 源码路径不是目录: {src}")
continue
dst = PLUGINS_DIR / pkg
# 排除 __pycache__,只拷 .py 文件
shutil.copytree(src, dst,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
copied.append(pkg)
print(f"[export] {pkg}: {src} (.py) -> {dst}")
except Exception as e:
print(f"[export] ERROR: 拷贝 {pkg} 源码失败: {e}")
return copied
def _copy_c_extensions() -> list[str]:
"""从 Python 安装目录拷贝 C 扩展 .pyd 到 plugins/。
C 扩展(如 _elementtree.pyd)是顶层模块,不在 stdlib 包目录里。
Windows 下 .pyd 可能在 py_root/ 或 py_root/DLLs/,都查一遍。
"""
copied: list[str] = []
py_root = Path(sys.base_prefix)
search_dirs = [py_root, py_root / "DLLs"]
for stdlib_pkg, ext_names in _manifest.COMPANION_C_EXTENSIONS.items():
for ext_name in ext_names:
candidates: list[Path] = []
for d in search_dirs:
candidates = list(d.glob(f"{ext_name}.*.pyd"))
if candidates:
break
candidates = list(d.glob(f"{ext_name}*.pyd"))
if candidates:
break
if not candidates:
print(f"[export] WARN: 找不到 C 扩展 {ext_name}.pyd"
f"(已查 {', '.join(str(d) for d in search_dirs)}")
continue
dst = PLUGINS_DIR / candidates[0].name
shutil.copy2(candidates[0], dst)
copied.append(ext_name)
print(f"[export] C 扩展 {ext_name}: {candidates[0]} -> {dst}")
return copied
def _read_plugin_meta() -> tuple[str, str, str]:
"""从 mil/__init__.py 源码提取插件名/版本/描述(绕过 PySide6 import)。
Returns:
(plugin_name, plugin_version, plugin_description)
"""
import re
src = (ROOT / "mil" / "__init__.py").read_text(encoding="utf-8")
name = re.search(r'NMAE\s*=\s*"([^"]+)"', src).group(1)
major = re.search(r"__MAJOR_VER:\s*int\s*=\s*(\d+)", src).group(1)
minor = re.search(r"__MINOR_VER:\s*int\s*=\s*(\d+)", src).group(1)
micro = re.search(r"__MICRO_VER:\s*int\s*=\s*(\d+)", src).group(1)
desc_match = re.search(r'DESCRIPITION\s*=\s*"""([\s\S]*?)"""', src)
desc = desc_match.group(1).strip() if desc_match else ""
return name, f"{major}.{minor}.{micro}", desc
def _write_manifest(copied_packages: list[str], stdlib_packages: list[str],
c_extensions: list[str]) -> None:
"""生成 plugins/manifest.json,含插件元数据供远程 zip 扫描读取。"""
name, version, desc = _read_plugin_meta()
manifest = {
"python_tag": _python_tag(),
"python_version": platform.python_version(),
"packages": copied_packages,
"stdlib": stdlib_packages,
"c_extensions": c_extensions,
"plugin_name": name,
"plugin_version": version,
"plugin_description": desc,
# 主业务包名固定为 mil,用作 zip 文件名前缀与本地子目录名
"tool_name": "mil",
}
dst = PLUGINS_DIR / "manifest.json"
dst.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"[export] manifest -> {dst}")
def main() -> None:
if not BUILD_DIR.exists():
print("[export] ERROR: build/ 不存在,请先运行 python build_pyd.py", file=sys.stderr)
sys.exit(1)
if PLUGINS_DIR.exists():
shutil.rmtree(PLUGINS_DIR)
PLUGINS_DIR.mkdir(parents=True)
copied_pyd = _copy_pyd_files()
# openpyxl/et_xmlfile/xml.etree 已编进 mil.pyd,不需要单独拷。
# 只需拷 C 扩展(_elementtree.pyd, pyexpat.pyd),运行时走标准 import 加载。
copied_cext = _copy_c_extensions()
_write_manifest(copied_pyd, [], copied_cext)
print(f"\n[export] 完成!plugins/ 目录已就绪:{PLUGINS_DIR}")
print(f"[export] .pyd 文件: {len(copied_pyd)} 个(含已编入的 openpyxl/et_xmlfile/xml.etree")
print(f"[export] C 扩展: {len(copied_cext)} 个(运行时从插件目录加载)")
print(f"[export] Python 版本: {platform.python_version()}")
print(f"[export] 下一步: 把 plugins/ 目录放到宿主 exe 旁边")
if __name__ == "__main__":
main()
-71
View File
@@ -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()
-118
View File
@@ -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(过渡期)。
注意:本文件不依赖任何第三方包,只用 stdlibos / sys / json / pathlib)。
因为它在宿主 sys.path 配置好之前执行,不能 import 任何外部包。
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
def _resolve_plugins_dir() -> Path | None:
"""定位 plugins/ 目录。
优先级:
1. 环境变量 MIL_PLUGIN_DIR
2. sys.executable 旁边的 plugins/onedir 模式)
3. sys._MEIPASS 旁边的 plugins/onefile 模式解压目录)
"""
# 1) 环境变量
env = os.environ.get("MIL_PLUGIN_DIR")
if env:
p = Path(env)
if p.is_dir():
return p.resolve()
# 2) onedirexe 旁边的 plugins/
exe_dir = Path(sys.executable).resolve().parent
plugins = exe_dir / "plugins"
if plugins.is_dir():
return plugins.resolve()
# 3) onefilePyInstaller 解压目录旁边的 plugins/
meipass = getattr(sys, "_MEIPASS", None)
if meipass:
plugins = Path(meipass).parent / "plugins"
if plugins.is_dir():
return plugins.resolve()
return None
def _check_python_version(manifest_path: Path) -> None:
"""校验插件的 Python 版本与当前解释器兼容(minor 版本必须一致)。"""
import json
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception:
return # manifest 损坏不阻止启动,让 import 错误自己暴露
plugin_py = manifest.get("python_version", "")
host_py = f"{sys.version_info.major}.{sys.version_info.minor}"
if not plugin_py.startswith(host_py):
sys.stderr.write(
f"[mil] WARNING: 插件 Python 版本({plugin_py}) 与宿主({host_py}) 可能不兼容\n"
)
def _insert_path(path: Path) -> None:
"""把 path 加到 sys.path[0],已存在则跳过。"""
p = str(path)
if p not in sys.path:
sys.path.insert(0, p)
def setup() -> None:
"""把 plugins/ 或 plugins/*/ 子目录加进 sys.path 最前面。
兼容两种布局:
- 旧扁平:plugins/ 直接含 manifest.json(过渡期)
- 新子目录:plugins/<插件名>/ 各自含 manifest.json
"""
plugins_dir = _resolve_plugins_dir()
if plugins_dir is None:
return
# 旧扁平布局:plugins/ 直接有 manifest.json
if (plugins_dir / "manifest.json").exists():
_check_python_version(plugins_dir / "manifest.json")
_insert_path(plugins_dir)
return
# 新子目录布局:逐个扫描 plugins/*/
for sub in plugins_dir.iterdir():
if not sub.is_dir():
continue
if (sub / "manifest.json").exists():
_check_python_version(sub / "manifest.json")
_insert_path(sub)
# 模块加载时立即执行(PyInstaller runtime hook 的约定)
setup()
-93
View File
@@ -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())