Compare commits

..
2 Commits
Author SHA1 Message Date
feifei.xu f188055228 清理无用代码、合并工具链、修复注释与日志问题
- 删除破损文件: main.py/main.ui/main_ui.py
- 删除 runtime_hook.py(归宿主维护),清除 tools/ 目录
- 合并 export_runtime/pack_zip/verify_companions 到 build_pyd.py
- 修复注释: docstring 参数与实际签名不一致、过时引用、拼写错误
- 修复日志: 消除静默吞异常、删冗余 log+raise、补缺失日志
- 精简 .gitignore
2026-07-21 18:14:34 +08:00
feifei.xu 91bb61444e feat: 增加伴生包加载器、manifest配置,Excel处理优化 2026-07-21 16:26:16 +08:00
23 changed files with 1012 additions and 361 deletions
+8 -106
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,42 +59,22 @@ 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
*.md *.md
/plugins
+441
View File
@@ -0,0 +1,441 @@
"""通过 Nuitka 把 mil 包及其纯 Python 伴生包编译为 .pyd,并完成导出、打包、部署、验证全流程。
策略说明:
- **mil 业务包**mil/ 整个包:core + ui)→ 编 pyd
- **Qt 桥接代码**mil/ui/*_ui.py)→ 保留为源码;
- **纯 Python 伴生包**COMPANIONS 列表)→ 逐个编 pyd,产物随 mil 一起分发;
- **Qt .ui 资源文件** → 运行时由 mil 包内的 uic.loadUiType 直接读源码路径;
- **PySide6** → --nofollow-import-to,运行时走宿主 pip 安装的二进制。
前置条件:
- openpyxl 3.x 的 C 扩展依赖 et_xmlfile 必须升到 >= 2.0(纯 Python 实现),
否则 Nuitka --module 模式会丢弃 _xmlfile.pyd 导致运行期 ImportError。
pip install --upgrade --force-reinstall "et_xmlfile>=2.0"
pip install --upgrade --force-reinstall openpyxl
用法(在工程根 F:\MyProject\mil_sdk 下):
python build_pyd.py # 编译 + 导出 + 打包 + 部署(默认全流程)
python build_pyd.py --clean # 清理 build/ 后走全流程
python build_pyd.py --no-progress # 关闭进度条(CI 友好)
python build_pyd.py --only mil # 只编指定包(逗号分隔)
python build_pyd.py --no-deploy # 不复制 zip 到网络盘
python build_pyd.py --verify # 单独验证伴生包加载链路
python build_pyd.py --verify --fallback # 验证时允许回退到 site-packages
全流程四步:
1. Nuitka 编译 .pyd → build/
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/ ← 中间产物
dist/
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 找。
"""
local = ROOT / pkg / "__init__.py"
if local.is_file():
return local
try:
spec = importlib.util.find_spec(pkg)
if spec is not None and spec.origin:
origin = Path(spec.origin)
if origin.is_file():
return origin
except (ImportError, ValueError):
pass
raise FileNotFoundError(f"找不到包 {pkg} 的入口 __init__.py")
def build_one(pkg: str, no_progress: bool) -> Path:
"""对单个包跑 Nuitka --module,产出 <pkg>.<python-tag>.pyd。
关键约束:--module 模式下要编""必须传"包目录路径"(末尾带 /),
不能传 __init__.py。Nuitka 看到目录就会自动按包模式处理。
"""
entry = _resolve_entry(pkg)
pkg_dir = entry.parent
pkg_name = pkg_dir.name
is_local = pkg_dir.parent == ROOT
module_arg = str(pkg_dir) + "/"
cmd: list[str] = [
sys.executable, "-m", "nuitka",
"--module",
module_arg,
f"--include-package={pkg_name}",
f"--output-dir={BUILD_DIR}",
]
if not is_local:
cmd += [
"--nofollow-import-to=xml",
"--nofollow-import-to=xml.etree",
"--nofollow-import-to=xml.etree.ElementTree",
"--nofollow-import-to=zipfile",
"--nofollow-import-to=tempfile",
"--nofollow-import-to=datetime",
"--nofollow-import-to=decimal",
"--nofollow-import-to=re",
"--nofollow-import-to=json",
"--nofollow-import-to=argparse",
"--nofollow-import-to=logging",
]
if is_local:
for pkg in _mod.COMPANION_PACKAGES_INLINE:
cmd.append(f"--include-package={pkg}")
for mod in _mod.COMPANION_STDLIB_INLINE:
cmd.append(f"--include-module={mod}")
for ext_pkgs in _mod.COMPANION_C_EXTENSIONS.values():
for ext in ext_pkgs:
cmd.append(f"--nofollow-import-to={ext}")
cmd.append("--nofollow-import-to=pyexpat")
cmd += ["--no-deployment-flag=frame-useless-set-trace"]
if no_progress:
cmd.append("--no-progress")
subprocess.run(cmd, cwd=str(pkg_dir.parent), check=True)
candidates = list(BUILD_DIR.glob(f"{pkg_name}.*.pyd"))
if not candidates:
candidates = list((BUILD_DIR / pkg_name).glob(f"{pkg_name}.*.pyd"))
if not candidates:
raise FileNotFoundError(
f"Nuitka 未产出预期的 {pkg_name}.*.pyd(已扫遍 build/"
)
return candidates[0]
# ============================================================
# 导出 plugins/ 分发包(原 tools/export_runtime.py
# ============================================================
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
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 _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)
if not zips:
print("[build_pyd] WARN: dist/ 下无 zip 文件,跳过部署", file=sys.stderr)
return
src = zips[0]
if not REMOTE_DEPLOY_DIR.exists():
print(f"[build_pyd] WARN: 远程目录不可达: {REMOTE_DEPLOY_DIR},跳过部署",
file=sys.stderr)
return
dst = REMOTE_DEPLOY_DIR / src.name
shutil.copy2(src, dst)
print(f"[build_pyd] 步骤 4/4: 已部署 {src.name} -> {dst}")
# ============================================================
# 验证伴生包加载链路(原 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("--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)
targets = ["mil"]
if args.only:
targets = [p.strip() for p in args.only.split(",") if p.strip()]
else:
targets += COMPANIONS
print("[build_pyd] 步骤 1/4: Nuitka 编译 .pyd")
for pkg in targets:
print(f"[build_pyd] 正在编译: {pkg}")
out = build_one(pkg, args.no_progress)
print(f"[build_pyd] {pkg} -> {out}")
try:
print("[build_pyd] 步骤 2/4: 导出 plugins/ 分发包")
_export_runtime()
print("[build_pyd] 步骤 3/4: 打包 zip")
_pack_zip()
except SystemExit as e:
if e.code != 0:
print(f"[build_pyd] 打包流程中止(退出码 {e.code}", file=sys.stderr)
sys.exit(e.code)
if not args.no_deploy:
_deploy_zip()
if __name__ == "__main__":
main()
-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
+11 -2
View File
@@ -3,6 +3,15 @@
"FilePath": "", "FilePath": "",
"AddTimeEn": true, "AddTimeEn": true,
"GeratePath": true, "GeratePath": true,
"CurrProject": "", "CurrProject": "ABC",
"ItemConfigs": {} "ItemConfigs": {
"吉利E211": {
"DataPath": "C:/Users/xff1atk/Downloads/模型检查单.xlsx",
"FilePath": "C:/Users/xff1atk/Downloads/Geely_E22H_Signallist.xlsx"
},
"ABC": {
"DataPath": "C:/Users/xff1atk/Downloads/Geely_E22H_Signallist.xlsx",
"FilePath": "C:/Users/xff1atk/Downloads/合肥模型团队-MIL测试模版V0.3.xlsx"
}
}
} }
-27
View File
@@ -1,27 +0,0 @@
# This file was generated by Nuitka
# Stubs included by default
from openpyxl import Workbook
from ui.mil_tool import FrameMILTool
def create_mil_tool(parent: typing.Any) -> typing.Any:
...
__name__ = ...
# Modules used internally, to allow implicit dependencies to be seen:
import os
import openpyxl
import logging
import dataclasses
import typing
import copy
import openpyxl.worksheet
import openpyxl.worksheet.worksheet
import _frozen_importlib_external
import PySide6
import PySide6.QtWidgets
import PySide6.QtCore
import PySide6.QtGui
+51 -2
View File
@@ -1,4 +1,53 @@
import re
from .ui.mil_tool import FrameMILTool from .ui.mil_tool import FrameMILTool
from .core.companion_loader import load_companions
from .core.manifest import COMPANION_PACKAGES
def create_mil_tool(parent=None): # 工具名称
return FrameMILTool(parent) NMAE = "MIL Tool"
# 工具版本
__MAJOR_VER: int = 0 # 主版本号
__MINOR_VER: int = 0 # 次版本号
__MICRO_VER: int = 6 # 修订版本号
VERSION:str = f"{__MAJOR_VER}.{__MINOR_VER}.{__MICRO_VER}"
# 工具描述
DESCRIPITION = """
主要用于生成Simulink Test Case。
"""
SVG = """
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 512 512"><path fill="#1de9b6" d="M495.855 367.604c-28.98-73.177-53.037-148.231-80.725-221.893c-12.23-31.362-24.198-62.986-40.868-92.33c-6.62-10.535-12.956-22.119-23.814-28.86c-2.739-1.74-5.828-2.479-8.88-2.42c-5.084.096-10.14 2.37-14.025 5.65c-14.571 11.543-23.209 28.334-32.896 43.793c-17.114 28.698-35.156 58.748-63.773 77.396c-13.44 9.485-31.039 10.514-43.995 20.686c-17.72 13.36-29.767 32.31-44.298 48.737c-3.31 3.956-8.436 5.53-12.916 7.67C86.315 243.71 42.945 261.35 0 279.916c36.367 28.132 75.115 53.157 112.208 80.321c10.172-2.018 20.383-6.196 30.877-4.339c16.63 5.207 26.377 21.15 34.006 35.721c15.5 31.765 26.7 65.307 39.253 98.283c20.988-1.493 39.878-12.31 56.104-25.025c31.24-25.307 55.034-58.001 81.23-88.192c12.552-13.199 25.347-28.92 44.197-32.593c19.05-4.601 39.454 2.22 53.581 15.338c19.818 17.719 37.376 38.203 60.544 51.765c-3.108-15.338-10.575-29.101-16.145-43.592M171.844 316.04c-18.284 10.817-37.699 19.717-56.71 29.162c-29.828-19.98-58.889-41.15-88.192-61.856c39.515-17.315 79.635-33.4 119.675-49.545c18.97 14.813 38.547 28.88 57.517 43.693c-8.92 14.248-19.657 27.487-32.29 38.546m40.665-49.646c-18.85-14.167-37.94-27.951-56.508-42.482c11.907-16.226 24.945-31.906 40.565-44.701c10.333-6.62 22.966-8.456 33.703-14.43c25.469-12.714 42.845-36.044 59.434-58.324c-21.271 55.216-43.975 110.654-77.194 159.937"/></svg>
"""
# 伴生包清单从 manifest 导入(唯一事实源),此处保留引用以兼容旧调用方
# 加新伴生包:改 mil/core/manifest.py 的 COMPANION_PACKAGES,不要改这里
def create_plugin(workspace, parent=None):
# 先挂伴生包,再构造 UI——mil 包内任何 import openpyxl 都将命中 build/ 下的 pyd
load_companions(COMPANION_PACKAGES)
return FrameMILTool(workspace, parent)
def create_plugin_svg(file:str):
with open(file,'w', encoding='utf-8') as f:
f.write(SVG.strip())
f.close()
def read_plugin_name():
return NMAE
def read_plugin_version():
return VERSION
def read_plugin_description():
return re.sub(r'^[\r\n]+',"",DESCRIPITION)
__all__ = [
"create_plugin",
"create_plugin_svg",
"read_plugin_name",
"read_plugin_version",
"read_plugin_description",
]
+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")
""" """
+17 -38
View File
@@ -9,20 +9,22 @@ from typing import Any
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@dataclass @dataclass
class Config: class Config():
"""MIL SDK 全局配置 """MIL SDK 全局配置
承载测试工程相关的全局开关与路径信息,是 UI 层与 core 层之间的 承载测试工程相关的全局开关与路径信息,是 UI 层与 core 层之间的
配置传输载体。序列化使用 JSON 文件持久化。 配置传输载体。序列化使用 JSON 文件持久化。
Attributes: Attributes:
DataPath: 仿真数据目录(Excel 原始文件所在路径 path: 配置文件路径(JSON 文件
DataPath: 仿真数据目录
FilePath: 当前打开的 Excel 文件路径 FilePath: 当前打开的 Excel 文件路径
AddTimeEn: 用例步骤时间是否按累加方式记录 AddTimeEn: 用例步骤时间是否按累加方式记录
GeratePath: 是否为生成的用例另存新文件 GeratePath: 是否为生成的用例另存新文件
CurrProject: 当前工程名称 CurrProject: 当前工程名称
ItemConfigs: 各模块/用例项的细粒度配置 ItemConfigs: 各模块/用例项的细粒度配置
""" """
path:str
DataPath:str = "" DataPath:str = ""
FilePath:str = "" FilePath:str = ""
AddTimeEn:bool = True AddTimeEn:bool = True
@@ -31,6 +33,8 @@ class Config:
# dict 是可变类型,必须用 default_factory 显式实例化,避免多个 Config 共享同一对象 # dict 是可变类型,必须用 default_factory 显式实例化,避免多个 Config 共享同一对象
ItemConfigs: dict = field(default_factory=dict) ItemConfigs: dict = field(default_factory=dict)
def check_path(self):
return os.path.exists(self.path)
def to_dict(self): def to_dict(self):
"""将 Config 实例序列化为 dict,便于写入 JSON 文件。""" """将 Config 实例序列化为 dict,便于写入 JSON 文件。"""
@@ -40,20 +44,17 @@ class Config:
"AddTimeEn":self.AddTimeEn, "AddTimeEn":self.AddTimeEn,
"GeratePath":self.GeratePath, "GeratePath":self.GeratePath,
"CurrProject":self.CurrProject, "CurrProject":self.CurrProject,
"ItemConfigs":self.ItemConfigs "ItemConfigs":self.ItemConfigs,
} }
def load_config(self, config_path: str) -> "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 实例,便于链式调用。
@@ -63,11 +64,11 @@ class Config:
""" """
try: try:
# 以 UTF-8 读取 JSON,避免 Windows 默认编码带来的乱码问题 # 以 UTF-8 读取 JSON,避免 Windows 默认编码带来的乱码问题
with open(config_path, 'r', encoding='utf-8') as f: with open(self.path, 'r', encoding='utf-8') as f:
raw = json.load(f) raw = json.load(f)
except FileNotFoundError: except FileNotFoundError:
# 显式记录并重抛,遵循"错误必须显式处理,禁止静默失败" # 显式记录并重抛,遵循"错误必须显式处理,禁止静默失败"
logger.error(f"配置文件{config_path}未找到") logger.error(f"配置文件{self.path}未找到")
raise raise
if not isinstance(raw, dict): if not isinstance(raw, dict):
@@ -81,25 +82,18 @@ class Config:
setattr(self, key, value) setattr(self, key, value)
return self return self
def save_config(self,config_path:str): def save_config(self):
"""将当前配置以 JSON 格式写入磁盘 """将当前配置以 JSON 格式写入 self.path 指定的文件
Args:
config_path: 配置文件路径。
Raises: Raises:
Exception: 写入失败时记录日志并原样抛出异常。 Exception: 写入失败时记录日志并原样抛出异常。
""" """
try: try:
with open(config_path,'w',encoding='utf-8') as f: with open(self.path,'w',encoding='utf-8') as f:
json.dump(self.to_dict(), f, indent=4, ensure_ascii=False) json.dump(self.to_dict(), f, indent=4, ensure_ascii=False)
except Exception as e: except Exception as e:
logger.error(f"配置文件{config_path}写入失败{e.args}") logger.error(f"配置文件{self.path}写入失败: {e}")
raise
@dataclass @dataclass
class DataLog: class DataLog:
@@ -107,11 +101,10 @@ class DataLog:
Attributes: Attributes:
time: 时间戳(秒) time: 时间戳(秒)
value: 信号值(可以是任意类型) value: 信号值(字符串形式,写入 Excel 时按内容推断 int/str 类型)
""" """
time: float = 0.0 time: float = 0.0
value: str = "" value: str = ""
@dataclass @dataclass
class SignalData: class SignalData:
@@ -127,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)
@@ -180,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
+104
View File
@@ -0,0 +1,104 @@
"""伴生包加载器:把纯 Python 伴生包从产物目录挂到 sys.modules。
设计目标:宿主 exe 一次打包后,mil 的伴生依赖(openpyxl / et_xmlfile / ...
可通过"覆盖 build/<pkg>/ 下的 .pyd 或 mil_runtime/<pkg>/ 下的源码"增量更新,
宿主不需要重打。
加载顺序(命中即停):
1. sys.modules 已存在(不动)
2. mil_runtime/<pkg>/ 下的纯 Python 源码(数据化部署)
3. build/<pkg>/ 下的预编译 .pyd(本轮主路径)
4. 都不命中 → 跳过并 warning,由调用方决定是否兜底
约定:
- mil 包的父目录下存在两个候选目录:mil_runtime/(源码)和 build/.pyd)。
- 解析顺序可通过环境变量 MIL_RUNTIME_DIR / MIL_BUILD_DIR 覆盖。
"""
from __future__ import annotations
import importlib.util
import logging
import os
import sys
from pathlib import Path
from types import ModuleType
logger = logging.getLogger(__name__)
DEFAULT_RUNTIME_DIRNAME = "mil_runtime"
DEFAULT_BUILD_DIRNAME = "build"
def _mil_root() -> Path:
"""获取 mil 包的父目录(即 mil_runtime/ 与 build/ 所在的工程根)。
不通过 `import mil` 解析位置,避免触发 mil 包完整初始化
(宿主侧必装 PySide6,开发侧不一定有)。
"""
# 本文件位于 <root>/mil/core/companion_loader.py,往上两级就是 <root>
return Path(__file__).resolve().parent.parent.parent
def _load_from_source(runtime_dir: Path, pkg: str) -> ModuleType | None:
"""尝试从 mil_runtime/<pkg>/ 加载纯 Python 源码包。"""
pkg_dir = runtime_dir / pkg
if not (pkg_dir / "__init__.py").is_file():
return None
spec = importlib.util.spec_from_file_location(
pkg,
pkg_dir / "__init__.py",
submodule_search_locations=[str(pkg_dir)],
)
if spec is None or spec.loader is None:
logger.warning(f"无法为 {pkg} 构造 specruntime 源码),跳过")
return None
module = importlib.util.module_from_spec(spec)
sys.modules[pkg] = module
spec.loader.exec_module(module)
return module
def _load_from_pyd(build_dir: Path, pkg: str) -> ModuleType | None:
"""尝试从 build/<pkg>/<pkg>.<python-tag>.pyd 加载预编译包。"""
candidates = list(build_dir.glob(f"{pkg}.*.pyd"))
if not candidates:
return None
pyd_path = candidates[0]
spec = importlib.util.spec_from_file_location(pkg, pyd_path)
if spec is None or spec.loader is None:
logger.warning(f"无法为 {pkg} 构造 specpyd={pyd_path}),跳过")
return None
module = importlib.util.module_from_spec(spec)
sys.modules[pkg] = module
spec.loader.exec_module(module)
return module
def load_companions(packages: list[str]) -> list[str]:
"""按上述顺序把每个伴生包挂到 sys.modules,返回成功挂载的包名列表。"""
root = _mil_root()
runtime_dir = Path(os.environ.get("MIL_RUNTIME_DIR", root / DEFAULT_RUNTIME_DIRNAME))
build_dir = Path(os.environ.get("MIL_BUILD_DIR", root / DEFAULT_BUILD_DIRNAME))
loaded: list[str] = []
for pkg in packages:
if pkg in sys.modules:
loaded.append(pkg)
continue
mod = (
_load_from_source(runtime_dir, pkg)
or _load_from_pyd(build_dir, pkg)
)
if mod is not None:
loaded.append(pkg)
logger.info(f"伴生包已挂载: {pkg}")
else:
logger.warning(
f"伴生包未找到: {pkg}runtime={runtime_dir} / build={build_dir} 均无)"
)
return loaded
+41
View File
@@ -0,0 +1,41 @@
"""插件运行期依赖清单。
这是唯一的事实源:
- build_pyd.py 读 COMPANION_PACKAGES_PYD 决定要编哪些伴生包;
- build_pyd.py 编译 mil 时用 --include-package 把 COMPANION_PACKAGES_INLINE 编进 mil.pyd
- build_pyd.py 导出步骤读 COMPANION_C_EXTENSIONS 决定要拷哪些 C 扩展。
加新伴生包时只动这里,其它脚本自动跟随。
"""
from __future__ import annotations
# 需 Nuitka 编译成独立 .pyd 的伴生包(业务代码需保护)
COMPANION_PACKAGES_PYD: list[str] = []
# 编译 mil 时用 --include-package 编进 mil.pyd 的纯 Python 包
# 这些包的 .py 代码编进 mil.pyd,运行时内部 hard-import,不查 sys.path
COMPANION_PACKAGES_INLINE: list[str] = [
"openpyxl",
"et_xmlfile",
]
# 编译 mil 时用 --include-module 编进 mil.pyd 的 stdlib 子模块
# Nuitka 默认不编译 stdlib--nofollow-stdlib),所以必须显式 include。
# 这些是 openpyxl/et_xmlfile 间接依赖的纯 Python stdlib,编进 mil.pyd 后
# 运行时 hard-import,不查 sys.path。只有 C 扩展(_elementtree)留给运行时。
COMPANION_STDLIB_INLINE: list[str] = [
"xml.etree",
"xml.etree.ElementTree",
"xml.etree.ElementPath",
"xml.etree.ElementInclude",
]
# 兼容旧接口:所有伴生包
COMPANION_PACKAGES: list[str] = COMPANION_PACKAGES_PYD + COMPANION_PACKAGES_INLINE
# 插件依赖的 C 扩展模块(Nuitka --module 编不进去,运行时从插件目录加载)
# key = 所属 stdlib 包名, value = C 扩展模块名列表
COMPANION_C_EXTENSIONS: dict[str, list[str]] = {
"xml": ["_elementtree"], # xml.etree.ElementTree 内部 from _elementtree import *
}
+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 类型错误")
# 计算"新增"与"删除"两组信号 # 计算"新增"与"删除"两组信号
+124
View File
@@ -0,0 +1,124 @@
import os
import logging
from PySide6.QtGui import *
from PySide6.QtCore import *
from PySide6.QtWidgets import *
logger = logging.getLogger(__name__)
from .mil_project_ui import Ui_FrameMILProject
from mil.core import Config
class MILProject(QFrame, Ui_FrameMILProject):
update_item_name_signal = Signal()
update_item_data_path_signal = Signal(str,str)
update_item_file_path_signal = Signal(str,str)
def __init__(self, config, name = None,parent = None):
super().__init__(parent)
self.setupUi(self)
self.initUI(config,name)
def initUI(self,config:Config, name:str):
self.lineEditDataPath.setReadOnly(True)
self.lineEditFilePath.setReadOnly(True)
self.name = name
self.config = config
self.ItemConfigs = config.ItemConfigs
if self.name is not None and self.name in self.ItemConfigs.keys():
self.lineEditName.setText(name)
self.lineEditDataPath.setText(self.ItemConfigs[self.name]["DataPath"])
self.lineEditFilePath.setText(self.ItemConfigs[self.name]["FilePath"])
else:
self.pushButtonDataPath.setEnabled(False)
self.pushButtonFilePath.setEnabled(False)
self.lineEditName.setPlaceholderText("必填项!!!")
self.lineEditDataPath.setPlaceholderText("点击【选择数据文件】按钮,选择路径!")
self.lineEditFilePath.setPlaceholderText("点击【选择测试用例】按钮,选择路径!")
self.lineEditName.editingFinished.connect(self.on_name_editing_finished_event)
self.lineEditDataPath.editingFinished.connect(self.on_datapath_editing_finished_event)
self.lineEditFilePath.editingFinished.connect(self.on_filepath_editing_finished_event)
self.pushButtonDataPath.clicked.connect(self.on_data_path_event)
self.pushButtonFilePath.clicked.connect(self.on_file_path_event)
def on_name_editing_finished_event(self):
name = self.lineEditName.text()
if name.replace(" ", "") == "":
self.lineEditName.setText(self.name)
logger.warning("项目名称是必填项!!!")
return
if name == self.name:
return
if self.name is None:
self.name = name
self.ItemConfigs[name] = dict()
self.ItemConfigs[name]["DataPath"] = ""
self.ItemConfigs[name]["FilePath"] = ""
else:
self.ItemConfigs[name] = self.ItemConfigs.pop(self.name)
if self.name == self.config.CurrProject:
self.config.CurrProject = name
self.name = name
self.config.save_config()
self.update_item_name_signal.emit()
self.pushButtonDataPath.setEnabled(True)
self.pushButtonFilePath.setEnabled(True)
def on_datapath_editing_finished_event(self):
name = self.lineEditName.text()
data_path = self.lineEditDataPath.text()
if data_path.replace(" ","") == "":
logger.warning("数据文件路径无效!")
return
self.ItemConfigs[name]["DataPath"] = data_path
self.config.save_config()
self.update_item_data_path_signal.emit(name,data_path)
def on_filepath_editing_finished_event(self):
name = self.lineEditName.text()
file_path = self.lineEditFilePath.text()
if file_path.replace(" ","") == "":
logger.warning("测试用例路径无效!")
return
self.ItemConfigs[name]["FilePath"] = file_path
self.config.save_config()
self.update_item_file_path_signal.emit(name,file_path)
def on_data_path_event(self):
dataPath = self.lineEditDataPath.text()
if os.path.exists(dataPath):
path,type = QFileDialog.getOpenFileName(self, "选择文件", dataPath,"Excel工作簿(*.xlsx)")
else:
path,type = QFileDialog.getOpenFileName(self, "选择文件", "./","Excel工作簿(*.xlsx)")
if not os.path.exists(path):
logger.debug(f"数据文件选择已取消: {dataPath}")
return
self.lineEditDataPath.setText(path)
self.on_datapath_editing_finished_event()
def on_file_path_event(self):
filePath = self.lineEditFilePath.text()
if os.path.exists(filePath):
path,type = QFileDialog.getOpenFileName(self, "选择文件", filePath,"Excel工作簿(*.xlsx)")
else:
path,type = QFileDialog.getOpenFileName(self, "选择文件", "./","Excel工作簿(*.xlsx)")
if not os.path.exists(path):
logger.debug(f"测试用例文件选择已取消: {filePath}")
return
self.lineEditFilePath.setText(path)
self.on_filepath_editing_finished_event()
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0"> <ui version="4.0">
<class>FrameProject</class> <class>FrameMILProject</class>
<widget class="QFrame" name="FrameProject"> <widget class="QFrame" name="FrameMILProject">
<property name="geometry"> <property name="geometry">
<rect> <rect>
<x>0</x> <x>0</x>
+23 -23
View File
@@ -18,26 +18,26 @@ from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
from PySide6.QtWidgets import (QApplication, QFrame, QGridLayout, QLabel, from PySide6.QtWidgets import (QApplication, QFrame, QGridLayout, QLabel,
QLineEdit, QPushButton, QSizePolicy, QWidget) QLineEdit, QPushButton, QSizePolicy, QWidget)
class Ui_FrameProject(object): class Ui_FrameMILProject(object):
def setupUi(self, FrameProject): def setupUi(self, FrameMILProject):
if not FrameProject.objectName(): if not FrameMILProject.objectName():
FrameProject.setObjectName(u"FrameProject") FrameMILProject.setObjectName(u"FrameMILProject")
FrameProject.resize(740, 95) FrameMILProject.resize(740, 95)
self.gridLayout_4 = QGridLayout(FrameProject) self.gridLayout_4 = QGridLayout(FrameMILProject)
self.gridLayout_4.setObjectName(u"gridLayout_4") self.gridLayout_4.setObjectName(u"gridLayout_4")
self.gridLayout = QGridLayout() self.gridLayout = QGridLayout()
self.gridLayout.setObjectName(u"gridLayout") self.gridLayout.setObjectName(u"gridLayout")
self.label = QLabel(FrameProject) self.label = QLabel(FrameMILProject)
self.label.setObjectName(u"label") self.label.setObjectName(u"label")
self.gridLayout.addWidget(self.label, 0, 0, 1, 1) self.gridLayout.addWidget(self.label, 0, 0, 1, 1)
self.label_2 = QLabel(FrameProject) self.label_2 = QLabel(FrameMILProject)
self.label_2.setObjectName(u"label_2") self.label_2.setObjectName(u"label_2")
self.gridLayout.addWidget(self.label_2, 1, 0, 1, 1) self.gridLayout.addWidget(self.label_2, 1, 0, 1, 1)
self.label_3 = QLabel(FrameProject) self.label_3 = QLabel(FrameMILProject)
self.label_3.setObjectName(u"label_3") self.label_3.setObjectName(u"label_3")
self.gridLayout.addWidget(self.label_3, 2, 0, 1, 1) self.gridLayout.addWidget(self.label_3, 2, 0, 1, 1)
@@ -47,7 +47,7 @@ class Ui_FrameProject(object):
self.gridLayout_2 = QGridLayout() self.gridLayout_2 = QGridLayout()
self.gridLayout_2.setObjectName(u"gridLayout_2") self.gridLayout_2.setObjectName(u"gridLayout_2")
self.lineEditName = QLineEdit(FrameProject) self.lineEditName = QLineEdit(FrameMILProject)
self.lineEditName.setObjectName(u"lineEditName") self.lineEditName.setObjectName(u"lineEditName")
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0) sizePolicy.setHorizontalStretch(0)
@@ -57,14 +57,14 @@ class Ui_FrameProject(object):
self.gridLayout_2.addWidget(self.lineEditName, 0, 0, 1, 1) self.gridLayout_2.addWidget(self.lineEditName, 0, 0, 1, 1)
self.lineEditDataPath = QLineEdit(FrameProject) self.lineEditDataPath = QLineEdit(FrameMILProject)
self.lineEditDataPath.setObjectName(u"lineEditDataPath") self.lineEditDataPath.setObjectName(u"lineEditDataPath")
sizePolicy.setHeightForWidth(self.lineEditDataPath.sizePolicy().hasHeightForWidth()) sizePolicy.setHeightForWidth(self.lineEditDataPath.sizePolicy().hasHeightForWidth())
self.lineEditDataPath.setSizePolicy(sizePolicy) self.lineEditDataPath.setSizePolicy(sizePolicy)
self.gridLayout_2.addWidget(self.lineEditDataPath, 1, 0, 1, 1) self.gridLayout_2.addWidget(self.lineEditDataPath, 1, 0, 1, 1)
self.lineEditFilePath = QLineEdit(FrameProject) self.lineEditFilePath = QLineEdit(FrameMILProject)
self.lineEditFilePath.setObjectName(u"lineEditFilePath") self.lineEditFilePath.setObjectName(u"lineEditFilePath")
sizePolicy.setHeightForWidth(self.lineEditFilePath.sizePolicy().hasHeightForWidth()) sizePolicy.setHeightForWidth(self.lineEditFilePath.sizePolicy().hasHeightForWidth())
self.lineEditFilePath.setSizePolicy(sizePolicy) self.lineEditFilePath.setSizePolicy(sizePolicy)
@@ -76,7 +76,7 @@ class Ui_FrameProject(object):
self.gridLayout_3 = QGridLayout() self.gridLayout_3 = QGridLayout()
self.gridLayout_3.setObjectName(u"gridLayout_3") self.gridLayout_3.setObjectName(u"gridLayout_3")
self.pushButtonDataPath = QPushButton(FrameProject) self.pushButtonDataPath = QPushButton(FrameMILProject)
self.pushButtonDataPath.setObjectName(u"pushButtonDataPath") self.pushButtonDataPath.setObjectName(u"pushButtonDataPath")
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
sizePolicy1.setHorizontalStretch(0) sizePolicy1.setHorizontalStretch(0)
@@ -86,7 +86,7 @@ class Ui_FrameProject(object):
self.gridLayout_3.addWidget(self.pushButtonDataPath, 0, 0, 1, 1) self.gridLayout_3.addWidget(self.pushButtonDataPath, 0, 0, 1, 1)
self.pushButtonFilePath = QPushButton(FrameProject) self.pushButtonFilePath = QPushButton(FrameMILProject)
self.pushButtonFilePath.setObjectName(u"pushButtonFilePath") self.pushButtonFilePath.setObjectName(u"pushButtonFilePath")
sizePolicy1.setHeightForWidth(self.pushButtonFilePath.sizePolicy().hasHeightForWidth()) sizePolicy1.setHeightForWidth(self.pushButtonFilePath.sizePolicy().hasHeightForWidth())
self.pushButtonFilePath.setSizePolicy(sizePolicy1) self.pushButtonFilePath.setSizePolicy(sizePolicy1)
@@ -97,18 +97,18 @@ class Ui_FrameProject(object):
self.gridLayout_4.addLayout(self.gridLayout_3, 0, 2, 1, 1) self.gridLayout_4.addLayout(self.gridLayout_3, 0, 2, 1, 1)
self.retranslateUi(FrameProject) self.retranslateUi(FrameMILProject)
QMetaObject.connectSlotsByName(FrameProject) QMetaObject.connectSlotsByName(FrameMILProject)
# setupUi # setupUi
def retranslateUi(self, FrameProject): def retranslateUi(self, FrameMILProject):
FrameProject.setWindowTitle(QCoreApplication.translate("FrameProject", u"Frame", None)) FrameMILProject.setWindowTitle(QCoreApplication.translate("FrameMILProject", u"Frame", None))
self.label.setText(QCoreApplication.translate("FrameProject", u"\u6a21\u578b\u9879\u76ee\uff1a", None)) self.label.setText(QCoreApplication.translate("FrameMILProject", u"\u6a21\u578b\u9879\u76ee\uff1a", None))
self.label_2.setText(QCoreApplication.translate("FrameProject", u"\u6570\u636e\u6587\u4ef6\uff1a", None)) self.label_2.setText(QCoreApplication.translate("FrameMILProject", u"\u6570\u636e\u6587\u4ef6\uff1a", None))
self.label_3.setText(QCoreApplication.translate("FrameProject", u"\u6d4b\u8bd5\u7528\u4f8b\uff1a", None)) self.label_3.setText(QCoreApplication.translate("FrameMILProject", u"\u6d4b\u8bd5\u7528\u4f8b\uff1a", None))
self.lineEditName.setText("") self.lineEditName.setText("")
self.pushButtonDataPath.setText(QCoreApplication.translate("FrameProject", u"\u9009\u62e9\u6570\u636e\u6587\u4ef6", None)) self.pushButtonDataPath.setText(QCoreApplication.translate("FrameMILProject", u"\u9009\u62e9\u6570\u636e\u6587\u4ef6", None))
self.pushButtonFilePath.setText(QCoreApplication.translate("FrameProject", u"\u9009\u62e9\u6d4b\u8bd5\u7528\u4f8b", None)) self.pushButtonFilePath.setText(QCoreApplication.translate("FrameMILProject", u"\u9009\u62e9\u6d4b\u8bd5\u7528\u4f8b", None))
# retranslateUi # retranslateUi
+178 -10
View File
@@ -1,26 +1,194 @@
import os import os
import logging import logging
from pathlib import Path
from .mil_tool_ui import Ui_FrameMILTool from .mil_tool_ui import Ui_FrameMILTool
from PySide6.QtWidgets import QFrame from PySide6.QtGui import *
from PySide6.QtCore import *
from PySide6.QtWidgets import *
from .mil_project import MILProject
from mil.core import Config 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):
def __init__(self,parent=None): def __init__(self,workspace:Path, parent=None):
super().__init__(parent) super().__init__(parent)
self.setupUi(self) self.setupUi(self)
self.initUI() self.initUI(workspace)
def initUI(self):
self.config = Config() def initUI(self, workspace:Path):
if os.path.exists(CONF): self.workspace = workspace
self.config.load_config(CONF) if not self.workspace.exists():
os.mkdir(self.workspace)
self.conf = self.workspace.joinpath(CONF)
self.config = Config(self.conf)
if self.config.check_path():
self.config.load_config()
self.checkBox.setChecked(self.config.AddTimeEn)
if self.config.GeratePath:
self.radioButtonData.setChecked(True)
else:
self.radioButtonFile.setChecked(True)
else: else:
self.config.save_config(CONF) self.config.save_config()
pass self.update_config()
for key in self.config.ItemConfigs.keys():
self.add_pro_event(key)
self.menu = QMenu(self.listWidget)
self.new_pro_acction = QAction("新建项目", self)
self.new_pro_acction.triggered.connect(self.on_new_pro_event)
self.del_pro_acction = QAction("删除项目", self)
self.del_pro_acction.triggered.connect(self.on_del_pro_event)
self.menu.addAction(self.new_pro_acction)
self.menu.addAction(self.del_pro_acction)
self.DataPath.setReadOnly(True)
self.FilePath.setReadOnly(True)
self.DataButton.clicked.connect(self.on_load_data_path)
self.FileButton.clicked.connect(self.on_load_file_path)
self.checkBox.toggled.connect(self.on_select_add_time_en)
self.radioButtonData.toggled.connect(self.on_select_gerate_path)
self.comboBox.currentTextChanged.connect(self.on_text_changed_event)
self.listWidget.customContextMenuRequested.connect(self.on_ContextMenuRequested)
def update_config(self):
self.comboBox.clear()
if self.config.ItemConfigs.keys().__len__() == 0:
return
self.lock_event = True
for key in self.config.ItemConfigs.keys():
self.comboBox.addItem(key)
self.lock_event = False
if self.config.CurrProject == "":
self.config.CurrProject = self.comboBox.currentText()
self.config.save_config()
self.DataPath.setText(self.config.ItemConfigs[self.comboBox.currentText()]["DataPath"])
self.FilePath.setText(self.config.ItemConfigs[self.comboBox.currentText()]["FilePath"])
else:
self.comboBox.setCurrentText(self.config.CurrProject)
self.DataPath.setText(self.config.ItemConfigs[self.config.CurrProject]["DataPath"])
self.FilePath.setText(self.config.ItemConfigs[self.config.CurrProject]["FilePath"])
def on_update_data_path_event(self,name,data_path):
if name == self.comboBox.currentText():
self.DataPath.setText(data_path)
def on_update_file_path_event(self,name,file_path):
if name == self.comboBox.currentText():
self.FilePath.setText(file_path)
def on_select_gerate_path(self,checked):
self.config.GeratePath = checked
self.config.save_config()
def on_select_add_time_en(self,checked):
self.config.AddTimeEn = checked
self.config.save_config()
def on_ContextMenuRequested(self,point:QPoint):
self.menu.exec_(QCursor.pos())
def on_text_changed_event(self,text:str):
if text == "" or self.lock_event:
return
self.config.CurrProject = text
self.config.save_config()
self.DataPath.setText(self.config.ItemConfigs[self.config.CurrProject]["DataPath"])
self.FilePath.setText(self.config.ItemConfigs[self.config.CurrProject]["FilePath"])
def on_new_pro_event(self):
item = QListWidgetItem()
mil_project = MILProject(self.config)
mil_project.update_item_name_signal.connect(self.update_config)
mil_project.update_item_data_path_signal.connect(self.on_update_data_path_event)
mil_project.update_item_file_path_signal.connect(self.on_update_file_path_event)
item.setSizeHint(QSize(mil_project.sizeHint().width(), 150))
self.listWidget.addItem(item)
self.listWidget.setCurrentItem(item)
self.listWidget.setItemWidget(item, mil_project)
def add_pro_event(self, name):
item = QListWidgetItem()
mil_project = MILProject(self.config, name=name)
mil_project.update_item_name_signal.connect(self.update_config)
mil_project.update_item_data_path_signal.connect(self.on_update_data_path_event)
mil_project.update_item_file_path_signal.connect(self.on_update_file_path_event)
item.setSizeHint(QSize(mil_project.sizeHint().width(), 150))
self.listWidget.addItem(item)
self.listWidget.setCurrentItem(item)
self.listWidget.setItemWidget(item, mil_project)
def on_del_pro_event(self):
mil_project = self.listWidget.itemWidget(self.listWidget.currentItem())
if not isinstance(mil_project, MILProject):
logger.error("on_del_pro_event: 当前项不是 MILProject 实例")
return
if self.config.CurrProject == mil_project.name:
self.config.CurrProject = ""
self.listWidget.takeItem(self.listWidget.currentRow())
self.config.ItemConfigs.pop(mil_project.name)
self.config.save_config()
self.update_config()
def search_mil_pro_item(self, name):
for mil_project in self.listWidget.findChildren(MILProject):
if not isinstance(mil_project,MILProject):
return
if mil_project.name == name:
return mil_project
return None
def on_load_data_path(self):
data_path = self.DataPath.text()
if os.path.exists(data_path):
path,type = QFileDialog.getOpenFileName(self, "选择文件", data_path,"Excel工作簿(*.xlsx)")
else:
path,type = QFileDialog.getOpenFileName(self, "选择文件", os.getcwd(),"Excel工作簿(*.xlsx)")
self.DataPath.setText(path)
if not path:
return
self.config.ItemConfigs[self.config.CurrProject]["DataPath"] = path
mil_project = self.search_mil_pro_item(self.config.CurrProject)
mil_project.lineEditDataPath.setText(path)
self.config.save_config()
def on_load_file_path(self):
file_path = self.FilePath.text()
if os.path.exists(file_path):
path,type = QFileDialog.getOpenFileName(self, "选择文件", file_path,"Excel工作簿(*.xlsx)")
else:
path,type = QFileDialog.getOpenFileName(self, "选择文件", os.getcwd(),"Excel工作簿(*.xlsx)")
self.FilePath.setText(path)
if not path:
return
self.config.ItemConfigs[self.config.CurrProject]["FilePath"] = path
mil_project = self.search_mil_pro_item(self.config.CurrProject)
mil_project.lineEditFilePath.setText(path)
self.config.save_config()
+6 -2
View File
@@ -17,7 +17,7 @@
<item row="0" column="0"> <item row="0" column="0">
<widget class="QTabWidget" name="tabWidget"> <widget class="QTabWidget" name="tabWidget">
<property name="tabPosition"> <property name="tabPosition">
<enum>QTabWidget::TabPosition::South</enum> <enum>QTabWidget::TabPosition::North</enum>
</property> </property>
<property name="currentIndex"> <property name="currentIndex">
<number>0</number> <number>0</number>
@@ -354,7 +354,11 @@
</attribute> </attribute>
<layout class="QGridLayout" name="gridLayout_11"> <layout class="QGridLayout" name="gridLayout_11">
<item row="0" column="0"> <item row="0" column="0">
<widget class="QListWidget" name="listWidget"/> <widget class="QListWidget" name="listWidget">
<property name="contextMenuPolicy">
<enum>Qt::ContextMenuPolicy::CustomContextMenu</enum>
</property>
</widget>
</item> </item>
</layout> </layout>
</widget> </widget>
+2 -1
View File
@@ -29,7 +29,7 @@ class Ui_FrameMILTool(object):
self.gridLayout_8.setObjectName(u"gridLayout_8") self.gridLayout_8.setObjectName(u"gridLayout_8")
self.tabWidget = QTabWidget(FrameMILTool) self.tabWidget = QTabWidget(FrameMILTool)
self.tabWidget.setObjectName(u"tabWidget") self.tabWidget.setObjectName(u"tabWidget")
self.tabWidget.setTabPosition(QTabWidget.TabPosition.South) self.tabWidget.setTabPosition(QTabWidget.TabPosition.North)
self.widget = QWidget() self.widget = QWidget()
self.widget.setObjectName(u"widget") self.widget.setObjectName(u"widget")
self.gridLayout_12 = QGridLayout(self.widget) self.gridLayout_12 = QGridLayout(self.widget)
@@ -272,6 +272,7 @@ class Ui_FrameMILTool(object):
self.gridLayout_11.setObjectName(u"gridLayout_11") self.gridLayout_11.setObjectName(u"gridLayout_11")
self.listWidget = QListWidget(self.tab_2) self.listWidget = QListWidget(self.tab_2)
self.listWidget.setObjectName(u"listWidget") self.listWidget.setObjectName(u"listWidget")
self.listWidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.gridLayout_11.addWidget(self.listWidget, 0, 0, 1, 1) self.gridLayout_11.addWidget(self.listWidget, 0, 0, 1, 1)
+1
View File
@@ -5,3 +5,4 @@ openpyxl>=3.0.0
# 开发依赖 # 开发依赖
pytest>=7.0.0 pytest>=7.0.0
nuitka