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

- 删除破损文件: 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
+273 -80
View File
@@ -1,8 +1,8 @@
"""通过 Nuitka 把 mil 包及其纯 Python 伴生包编译为 .pyd。
"""通过 Nuitka 把 mil 包及其纯 Python 伴生包编译为 .pyd,并完成导出、打包、部署、验证全流程
策略说明:
- **mil 业务包**mil/ 整个包:core + ui)→ 编 pyd
- **Qt 桥接代码**mil/ui/*_ui.py、main_ui.py)→ 保留为源码;
- **Qt 桥接代码**mil/ui/*_ui.py)→ 保留为源码;
- **纯 Python 伴生包**COMPANIONS 列表)→ 逐个编 pyd,产物随 mil 一起分发;
- **Qt .ui 资源文件** → 运行时由 mil 包内的 uic.loadUiType 直接读源码路径;
- **PySide6** → --nofollow-import-to,运行时走宿主 pip 安装的二进制。
@@ -19,47 +19,91 @@
python build_pyd.py --no-progress # 关闭进度条(CI 友好)
python build_pyd.py --only mil # 只编指定包(逗号分隔)
python build_pyd.py --no-deploy # 不复制 zip 到网络盘
python build_pyd.py --verify # 单独验证伴生包加载链路
python build_pyd.py --verify --fallback # 验证时允许回退到 site-packages
全流程四步:
1. Nuitka 编译 .pyd → build/
2. export_runtime 聚合 → plugins/
3. pack_zip 打包 → dist/<tool>-<ver>-<tag>.zip
2. export 聚合 → plugins/
3. pack 打包 → dist/<tool>-<ver>-<tag>.zip
4. 复制 zip → Y:/SE/xufeifei/plugins/--no-deploy 跳过)
产物布局:
build/
mil.cp311-win_amd64.pyd ← 业务包
plugins/ ← export_runtime.py 生成(中间产物
plugins/ ← 中间产物
dist/
mil-<version>-cp311-win_amd64.zip ← pack_zip.py 生成(最终交付物
mil-<version>-cp311-win_amd64.zip ← 最终交付物
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import platform
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent
BUILD_DIR = ROOT / "build"
PLUGINS_DIR = ROOT / "plugins"
DIST_DIR = ROOT / "dist"
REMOTE_DEPLOY_DIR = Path("Y:/SE/xufeifei/plugins")
# ============================================================
# 共享工具函数
# ============================================================
def _python_tag() -> str:
"""返回 Python ABI tag,如 cp311-win_amd64。"""
impl = "cp" + str(sys.version_info.major) + str(sys.version_info.minor)
plat = "win_amd64" if platform.system() == "Windows" else platform.machine()
return f"{impl}-{plat}"
def _read_plugin_meta() -> tuple[str, str, str]:
"""从 mil/__init__.py 源码提取插件名/版本/描述(绕过 PySide6 import)。"""
src = (ROOT / "mil" / "__init__.py").read_text(encoding="utf-8")
name = re.search(r'NMAE\s*=\s*"([^"]+)"', src).group(1)
major = re.search(r"__MAJOR_VER:\s*int\s*=\s*(\d+)", src).group(1)
minor = re.search(r"__MINOR_VER:\s*int\s*=\s*(\d+)", src).group(1)
micro = re.search(r"__MICRO_VER:\s*int\s*=\s*(\d+)", src).group(1)
desc_match = re.search(r'DESCRIPITION\s*=\s*"""([\s\S]*?)"""', src)
desc = desc_match.group(1).strip() if desc_match else ""
return name, f"{major}.{minor}.{micro}", desc
# ============================================================
# 伴生包清单(从 manifest.py 加载,绕过 mil/__init__.py 的 PySide6 依赖)
# ============================================================
_spec = importlib.util.spec_from_file_location(
"mil_manifest", ROOT / "mil" / "core" / "manifest.py"
)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
COMPANIONS: list[str] = _mod.COMPANION_PACKAGES_PYD
# ============================================================
# Nuitka 编译
# ============================================================
def _resolve_entry(pkg: str) -> Path:
"""定位包的入口 __init__.py。
业务包(mil)从工程根找;第三方包从当前 Python 的 site-packages 找。
"""
# 1) 业务包:从工程根找(mil 与 build_pyd.py 同级)
local = ROOT / pkg / "__init__.py"
if local.is_file():
return local
# 2) 第三方包:从当前解释器的 site-packages 找
try:
import importlib.util
spec = importlib.util.find_spec(pkg)
if spec is not None and spec.origin:
origin = Path(spec.origin)
@@ -70,49 +114,31 @@ def _resolve_entry(pkg: str) -> Path:
raise FileNotFoundError(f"找不到包 {pkg} 的入口 __init__.py")
# 要打成 .pyd 的纯 Python 伴生包清单(必须无 .pyd / .so / .dylib)。
# 从 manifest 读取,保持唯一事实源。
# openpyxl/et_xmlfile 改为 .py 源码分发(Nuitka .pyd 对 stdlib import 有 hard-import
# 优化,运行时不查 sys.path,导致插件自带的 stdlib xml 找不到)。
import importlib.util as _ilu
_spec = _ilu.spec_from_file_location("mil_manifest", ROOT / "mil" / "core" / "manifest.py")
_mod = _ilu.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
COMPANIONS: list[str] = _mod.COMPANION_PACKAGES_PYD
def build_one(pkg: str, no_progress: bool) -> Path:
"""对单个包跑 Nuitka --module,产出 <pkg>.<python-tag>.pyd。
区分业务包与第三方伴生包:
- 业务包(mil):保留原有逻辑,让 Nuitka 跟进所有依赖;
- 第三方包:显式排除 stdlib 子模块,避免"半截包"问题
(--module 模式物理上编不进 C 加速模块,会留下残缺包导致运行期 ImportError)。
关键约束:--module 模式下要编""必须传"包目录路径"(末尾带 /),
不能传 __init__.py。Nuitka 看到目录就会自动按包模式处理。
"""
entry = _resolve_entry(pkg) # 入口解析保留,仅用于校验包存在
entry = _resolve_entry(pkg)
pkg_dir = entry.parent
pkg_name = pkg_dir.name
is_local = pkg_dir.parent == ROOT # 工程内业务包 vs site-packages 第三方包
is_local = pkg_dir.parent == ROOT
# 末尾带 / —— Nuitka 区分"目录 vs 文件"的关键
module_arg = str(pkg_dir) + "/"
cmd: list[str] = [
sys.executable, "-m", "nuitka",
"--module",
module_arg, # ← 包目录,不是 __init__.py
module_arg,
f"--include-package={pkg_name}",
f"--output-dir={BUILD_DIR}", # 绝对路径,避免被 cwd 影响
f"--output-dir={BUILD_DIR}",
]
# 仅对第三方包显式排除 stdlib 子模块,避免半截包问题。
# 运行时这些 stdlib 走宿主 Python 自带版本(任何合规安装都有)。
if not is_local:
cmd += [
"--nofollow-import-to=xml", # openpyxl/xml/functions.py:40 用到 iterparse
"--nofollow-import-to=xml",
"--nofollow-import-to=xml.etree",
"--nofollow-import-to=xml.etree.ElementTree",
"--nofollow-import-to=zipfile",
@@ -125,39 +151,22 @@ def build_one(pkg: str, no_progress: bool) -> Path:
"--nofollow-import-to=logging",
]
# 对业务包(mil):把纯 Python 依赖编进 mil.pyd,C 扩展留给运行时加载。
#
# 原理:openpyxl/et_xmlfile/xml.etree 都是纯 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:
# 从 manifest 读取要编进 mil.pyd 的纯 Python 包
for pkg in _mod.COMPANION_PACKAGES_INLINE:
cmd.append(f"--include-package={pkg}")
# Nuitka 默认不编译 stdlib,显式 include openpyxl 间接依赖的 stdlib 子模块
for mod in _mod.COMPANION_STDLIB_INLINE:
cmd.append(f"--include-module={mod}")
# C 扩展不编进 .pyd,运行时从插件目录加载
for ext_pkgs in _mod.COMPANION_C_EXTENSIONS.values():
for ext in ext_pkgs:
cmd.append(f"--nofollow-import-to={ext}")
# pyexpat 是 _elementtree 的底层 C 扩展,也需 nofollow
cmd.append("--nofollow-import-to=pyexpat")
cmd += ["--no-deployment-flag=frame-useless-set-trace"]
if no_progress:
cmd.append("--no-progress")
# cwd 切到包所在目录的父目录:Nuitka 在此目录下识别包名
subprocess.run(cmd, cwd=str(pkg_dir.parent), check=True)
# 兼容两种产物布局:
# 1) build/<pkg>.<python-tag>.pyd ← Nuitka --module 默认
# 2) build/<pkg>/<pkg>.<python-tag>.pyd ← 旧版本 / 某些参数组合
candidates = list(BUILD_DIR.glob(f"{pkg_name}.*.pyd"))
if not candidates:
candidates = list((BUILD_DIR / pkg_name).glob(f"{pkg_name}.*.pyd"))
@@ -168,33 +177,134 @@ def build_one(pkg: str, no_progress: bool) -> Path:
return candidates[0]
def _export_and_pack() -> None:
"""编译完成后,串调 export_runtime + pack_zip 生成最终 zip。
# ============================================================
# 导出 plugins/ 分发包(原 tools/export_runtime.py
# ============================================================
两个工具脚本都暴露 main(),直接 import 调用,避免 subprocess 开销与
重复的 importlib 加载 manifest 逻辑。子脚本失败时抛 SystemExit,
此处捕获并提前返回(后续步骤无意义)。
"""
tools_dir = ROOT / "tools"
if str(tools_dir) not in sys.path:
sys.path.insert(0, str(tools_dir))
def _copy_pyd_files() -> list[str]:
"""把 build/ 下的 .pyd 拷到 plugins/,返回已拷贝的包名列表。"""
copied: list[str] = []
for pkg in _mod.COMPANION_PACKAGES + ["mil"]:
matches = list(BUILD_DIR.glob(f"{pkg}.*.pyd"))
if not matches:
matches = list((BUILD_DIR / pkg).glob(f"{pkg}.*.pyd"))
if not matches:
print(f"[export] WARN: build/ 下未找到 {pkg}.*.pyd,跳过")
continue
try:
print("[build_pyd] 步骤 2/4: 导出 plugins/ 分发包")
import export_runtime
export_runtime.main()
dst = PLUGINS_DIR / matches[0].name
shutil.copy2(matches[0], dst)
copied.append(pkg)
print(f"[export] {pkg}: {matches[0].name} -> {dst}")
print("[build_pyd] 步骤 3/4: 打包 zip")
import pack_zip
pack_zip.main()
except SystemExit as e:
# 子脚本 sys.exit(1) 表示前置条件不满足(如 build/ 为空),直接中止
if e.code != 0:
print(f"[build_pyd] 打包流程中止(退出码 {e.code}", file=sys.stderr)
return False
return True
return copied
def _copy_c_extensions() -> list[str]:
"""从 Python 安装目录拷贝 C 扩展 .pyd 到 plugins/。"""
copied: list[str] = []
py_root = Path(sys.base_prefix)
search_dirs = [py_root, py_root / "DLLs"]
for stdlib_pkg, ext_names in _mod.COMPANION_C_EXTENSIONS.items():
for ext_name in ext_names:
candidates: list[Path] = []
for d in search_dirs:
candidates = list(d.glob(f"{ext_name}.*.pyd"))
if candidates:
break
candidates = list(d.glob(f"{ext_name}*.pyd"))
if candidates:
break
if not candidates:
print(f"[export] WARN: 找不到 C 扩展 {ext_name}.pyd"
f"(已查 {', '.join(str(d) for d in search_dirs)}")
continue
dst = PLUGINS_DIR / candidates[0].name
shutil.copy2(candidates[0], dst)
copied.append(ext_name)
print(f"[export] C 扩展 {ext_name}: {candidates[0]} -> {dst}")
return copied
def _write_manifest(copied_packages: list[str], stdlib_packages: list[str],
c_extensions: list[str]) -> None:
"""生成 plugins/manifest.json,供宿主 runtime hook 读取并配置 sys.path。"""
name, version, desc = _read_plugin_meta()
manifest = {
"python_tag": _python_tag(),
"python_version": platform.python_version(),
"packages": copied_packages,
"stdlib": stdlib_packages,
"c_extensions": c_extensions,
"plugin_name": name,
"plugin_version": version,
"plugin_description": desc,
"tool_name": "mil",
}
dst = PLUGINS_DIR / "manifest.json"
dst.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"[export] manifest -> {dst}")
def _export_runtime() -> None:
"""导出插件分发包到 plugins/ 目录。"""
if not BUILD_DIR.exists():
print("[export] ERROR: build/ 不存在,请先运行 python build_pyd.py", file=sys.stderr)
raise SystemExit(1)
if PLUGINS_DIR.exists():
shutil.rmtree(PLUGINS_DIR)
PLUGINS_DIR.mkdir(parents=True)
copied_pyd = _copy_pyd_files()
copied_cext = _copy_c_extensions()
_write_manifest(copied_pyd, [], copied_cext)
print(f"\n[export] 完成!plugins/ 目录已就绪:{PLUGINS_DIR}")
print(f"[export] .pyd 文件: {len(copied_pyd)} 个(含已编入 mil.pyd 的 openpyxl/et_xmlfile")
print(f"[export] C 扩展: {len(copied_cext)} 个(运行时从插件目录加载)")
print(f"[export] Python 版本: {platform.python_version()}")
print(f"[export] 下一步: 把 plugins/ 目录放到宿主 exe 旁边")
# ============================================================
# 打包 zip 分发包(原 tools/pack_zip.py
# ============================================================
def _pack_zip() -> None:
"""把 plugins/ 目录打包成带版本号的 zip 分发包。"""
if not PLUGINS_DIR.exists() or not any(PLUGINS_DIR.iterdir()):
print("[pack] ERROR: plugins/ 不存在或为空,请先执行导出步骤", file=sys.stderr)
raise SystemExit(1)
name, version, _ = _read_plugin_meta()
manifest = json.loads((PLUGINS_DIR / "manifest.json").read_text(encoding="utf-8"))
tool_name = manifest["tool_name"]
zip_name = f"{tool_name}-{version}-{_python_tag()}.zip"
DIST_DIR.mkdir(parents=True, exist_ok=True)
zip_path = DIST_DIR / zip_name
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for f in PLUGINS_DIR.rglob("*"):
if f.is_file():
arcname = f.relative_to(PLUGINS_DIR).as_posix()
zf.write(f, arcname)
file_count = len(zipfile.ZipFile(zip_path).namelist())
print(f"[pack] 打包完成: {zip_path}")
print(f"[pack] 插件: {name} v{version}")
print(f"[pack] 文件数: {file_count}")
print(f"[pack] 下一步: 把 zip 复制到远程 {REMOTE_DEPLOY_DIR}")
# ============================================================
# 部署
# ============================================================
def _deploy_zip() -> None:
"""把 dist/ 下最新生成的 zip 复制到网络盘远程分发目录。"""
zips = sorted(DIST_DIR.glob("*.zip"), key=lambda f: f.stat().st_mtime, reverse=True)
@@ -211,19 +321,92 @@ def _deploy_zip() -> None:
print(f"[build_pyd] 步骤 4/4: 已部署 {src.name} -> {dst}")
# ============================================================
# 验证伴生包加载链路(原 tools/verify_companions.py
# ============================================================
def verify_companions(fallback: bool = False) -> int:
"""验证 build/ 下伴生包 .pyd 的 openpyxl 读写闭环是否可用。
直接 import mil.core.companion_loader,不经过 mil/__init__.py
避免在无 PySide6 的开发机上因 UI 桥接而炸。
"""
spec = importlib.util.spec_from_file_location(
"mil_companion_loader", ROOT / "mil" / "core" / "companion_loader.py"
)
loader_mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(loader_mod)
load_companions = loader_mod.load_companions
packages = list(_mod.COMPANION_PACKAGES)
loaded = load_companions(packages)
print(f"[verify] 加载器返回: {loaded}")
if not loaded and not fallback:
print(
"[verify] FAIL: 没有挂载到任何伴生包(是否忘了 python build_pyd.py?)\n"
" 调试期可加 --fallback 走 site-packages 自测加载器逻辑。",
file=sys.stderr,
)
return 1
if not loaded and fallback:
import importlib as _il
for pkg in packages:
_il.import_module(pkg)
loaded.append(pkg)
print(f"[verify] fallback 后挂载: {loaded}")
import openpyxl # noqa: WPS433
print(f"[verify] openpyxl 来自: {openpyxl.__file__}")
with tempfile.TemporaryDirectory() as td:
xlsx_path = Path(td) / "smoke.xlsx"
wb = openpyxl.Workbook()
ws = wb.active
ws["A1"] = "time"
ws["B1"] = "sig1"
ws["A2"] = 0.0
ws["B2"] = 1
ws["A3"] = 1.0
ws["B3"] = 2
wb.save(xlsx_path)
wb2 = openpyxl.load_workbook(xlsx_path)
ws2 = wb2.active
assert ws2["A1"].value == "time", ws2["A1"].value
assert ws2["B1"].value == "sig1", ws2["B1"].value
assert float(ws2["A3"].value) == 1.0, ws2["A3"].value
assert int(ws2["B3"].value) == 2, ws2["B3"].value
print("[verify] PASS: openpyxl 写读闭环 ✓")
return 0
# ============================================================
# 主入口
# ============================================================
def main() -> None:
"""编译 mil 包及其伴生依赖为 .pyd,导出为 plugins/ 目录,打包为 zip 并部署至网络盘。
通过 --verify 可跳过编译,仅验证已编译产物的 openpyxl 读写闭环。
"""
parser = argparse.ArgumentParser(description="编译 mil + 伴生包为 .pyd,并打包成 zip")
parser.add_argument("--clean", action="store_true", help="清理 build/ 与上次产物")
parser.add_argument("--no-progress", action="store_true", help="关闭进度条(CI 友好)")
parser.add_argument("--no-deploy", action="store_true", help="不复制 zip 到网络盘")
parser.add_argument(
"--only",
type=str,
default="",
help="只编指定包(逗号分隔),如 --only mil,openpyxl;默认全编",
)
parser.add_argument("--only", type=str, default="",
help="只编指定包(逗号分隔),如 --only mil,openpyxl;默认全编")
parser.add_argument("--verify", action="store_true", help="验证伴生包 .pyd 加载链路(不编译)")
parser.add_argument("--fallback", action="store_true",
help="验证时,未发现产物则回退到 site-packages(开发自测用)")
args = parser.parse_args()
if args.verify:
sys.exit(verify_companions(args.fallback))
if args.clean and BUILD_DIR.exists():
shutil.rmtree(BUILD_DIR)
@@ -239,10 +422,20 @@ def main() -> None:
out = build_one(pkg, args.no_progress)
print(f"[build_pyd] {pkg} -> {out}")
ok = _export_and_pack()
if ok and not args.no_deploy:
try:
print("[build_pyd] 步骤 2/4: 导出 plugins/ 分发包")
_export_runtime()
print("[build_pyd] 步骤 3/4: 打包 zip")
_pack_zip()
except SystemExit as e:
if e.code != 0:
print(f"[build_pyd] 打包流程中止(退出码 {e.code}", file=sys.stderr)
sys.exit(e.code)
if not args.no_deploy:
_deploy_zip()
if __name__ == "__main__":
main()
main()