Files
model_team_tools/core/plugin/plugin_registry.py
T
feifei.xu 1b6796ec43 docs: 完善README文档与架构图,修复全量注释与日志问题
- README: 补充项目概述、技术栈、架构图(Mermaid)、插件生命周期时序图
- build.py: 补充模块和函数 docstring
- core/__init__.py: 修正模块说明(工具箱→插件系统)
- log.py: 修复过时注释,清理死代码,抑制第三方库日志刷屏
- plugin_registry.py: 修正矛盾注释,补充缺失日志,隐藏远程路径
- plugins.py: 修正过时注释,清理死代码和无用导入
- plugins_card.py: 修正错误类名注释,补充全量 docstring
- main.py: 修正错误注释,补充启动/退出/插件加载日志
2026-07-21 17:04:34 +08:00

472 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""插件注册中心。
架构说明:
- PluginWorker 运行在独立 QThread 中,仅做纯 I/O(扫描 zip/.pyd、下载解压、
读 manifest 元数据),不创建 QObject、不操作 QWidget。
- PluginRegistry 留在主线程,负责 create_plugin()(返回 QWidget,必须在 GUI 线程
创建)与信号转发。
- 跨线程通信全部走 Qt 信号槽(自动 Queued),不再使用 threading.Thread。
分发模型:
- 远程:每插件一个 <tool>-<version>-<python_tag>.zipexport_runtime + pack_zip 产物)
- 本地:解压到 plugins/<tool_name>/ 子目录,卸载删整个子目录
"""
import os
import sys
import time
import json
import shutil
import zipfile
import logging
import importlib
import importlib.util
from pathlib import Path
from enum import Enum, auto
from typing import Optional
from PySide6.QtCore import QObject, Signal, Slot, QThread, QTimer
WORKSPACE_PATH = Path("./workspace")
LOCAL_PLUGINS_PATH = Path("./plugins")
REMOTE_PLUGINS_PATH = Path('Y:/SE/xufeifei/plugins')
UNINSTALL_PENDING_FILE = LOCAL_PLUGINS_PATH / ".uninstall_pending"
logger = logging.getLogger(__name__)
def _load_module_from_path(module_name: str, file_path: Path):
"""按文件路径加载模块,绕过 sys.modules 缓存。
用 spec_from_file_location 直接从 .pyd 文件构建模块,避免
importlib.import_module 命中同名旧缓存。注意:.pyd 的入口函数
PyInit_<name> 与文件名绑定,module_name 必须等于 _pyd_module_name()
提取的模块名(第一个点号之前的部分),不能直接用完整 stem。
例如 mil.cp311-win_amd64.pyd 的 module_name 必须是 "mil"。
"""
spec = importlib.util.spec_from_file_location(module_name, file_path)
if spec is None or spec.loader is None:
raise ImportError(f"无法为 {file_path} 创建模块规格")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
def _pyd_module_name(file_path: Path) -> str:
"""从 .pyd 文件名提取 Python 模块名(PyInit_ 后缀)。
Nuitka 产物形如 mil.cp311-win_amd64.pydC 扩展入口函数为
PyInit_mil,因此模块名必须取第一个点之前的部分,不能用完整 stem。
"""
return file_path.name.split('.', 1)[0]
class Event(Enum):
Update = auto()
Install = auto()
Uninstall = auto()
class PluginWorker(QObject):
"""插件发现与安装 worker,运行在独立 QThread 中。
仅做纯 I/O,结果以纯数据(list[dict])形式经信号回传主线程。
"""
local_discovered = Signal(list)
remote_discovered = Signal(list)
install_progress = Signal(int, int)
install_finished = Signal(bool, str)
uninstall_finished = Signal(str, bool, str)
@Slot(str)
def do_discover_local(self, plugins_dir: str) -> None:
"""扫描 plugins/<插件名>/ 子目录下的主插件 .pyd。"""
plugins_dir = Path(plugins_dir)
if not self._ensure_dir(plugins_dir):
return
self.local_discovered.emit(self._scan_local_plugins(plugins_dir))
@Slot(str)
def do_discover_remote(self, plugins_dir: str) -> None:
"""扫描远程 *.zip,读 zip 内 manifest.json 取插件元数据。"""
if not os.path.exists(plugins_dir):
logger.error(f"远程插件路径不可达")
return
logger.info(f"开始扫描远程插件 zip")
self.remote_discovered.emit(
self._scan_remote_zips(Path(plugins_dir))
)
@Slot(str, str)
def do_install(self, local_dir: str, remote_zip: str) -> None:
"""下载 zip 流式写本地临时文件,再解压到 plugins/<插件名>/。
流式下载保留 install_progress 信号,便于 UI 显示进度条。
"""
try:
total_size = os.path.getsize(remote_zip)
target = Path(local_dir)
target.mkdir(parents=True, exist_ok=True)
tmp_zip = target / ".download.tmp"
copied = 0
with open(remote_zip, 'rb') as fsrc, open(tmp_zip, 'wb') as fdst:
while True:
buf = fsrc.read(1024 * 1024)
if not buf:
break
fdst.write(buf)
copied += len(buf)
self.install_progress.emit(copied, total_size)
with zipfile.ZipFile(tmp_zip, 'r') as zf:
zf.extractall(target)
tmp_zip.unlink()
self.install_finished.emit(True, f"安装成功: {local_dir}")
except Exception as e:
logger.error(f"{remote_zip} 安装失败: {e}")
self.install_finished.emit(False, str(e))
@Slot(str, str)
def do_uninstall(self, name: str, local_dir: str) -> None:
"""运行时无法删除已加载 .pyd,记入待删清单(目录路径),重启时删整个目录。
真正的目录删除由 PluginRegistry._process_pending_uninstalls 在下次启动、
import 之前完成。运行时清理(字典/UI/widget)由主线程在 uninstall_finished
回调中处理。
"""
try:
UNINSTALL_PENDING_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(UNINSTALL_PENDING_FILE, 'a', encoding='utf-8') as f:
f.write(f"{local_dir}\n")
self.uninstall_finished.emit(name, True, f"{name}已加入卸载清单,重启后生效!!!")
except Exception as e:
logger.error(f"{local_dir}卸载失败{e.args}")
self.uninstall_finished.emit(name, False, str(e))
@Slot(str, str, str)
def do_update(self, name: str, local_dir: str, remote_zip: str) -> None:
"""更新=删旧目录+从远程 zip 解压新版本。运行时记清单,重启时执行。
运行时 .pyd 已加载无法删除,记入待处理清单(含 remote_zip),
重启时由 _process_pending_uninstalls 删除旧目录并解压新 zip。
运行时 UI 清理复用 uninstall_finished 信号链路,触发与卸载一致的自重启。
"""
try:
UNINSTALL_PENDING_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(UNINSTALL_PENDING_FILE, 'a', encoding='utf-8') as f:
f.write(f"{local_dir}|{remote_zip}\n")
self.uninstall_finished.emit(name, True, f"{name}更新已加入清单,重启后生效!!!")
except Exception as e:
logger.error(f"{local_dir}更新失败{e.args}")
self.uninstall_finished.emit(name, False, str(e))
@staticmethod
def _ensure_dir(plugins_dir: Path) -> bool:
if not os.path.exists(plugins_dir):
os.mkdir(plugins_dir)
logger.info(f"插件目录已创建: {plugins_dir}")
return False
return True
@staticmethod
def _scan_local_plugins(plugins_dir: Path) -> list:
"""扫描 plugins/<插件名>/ 子目录,读主插件 .pyd 元数据。
跳过伴生包(openpyxl/et_xmlfile)与 C 扩展(下划线开头)。
每个插件子目录下只应有一个主插件 .pyd。
"""
results: list = []
for sub in plugins_dir.iterdir():
if not sub.is_dir():
continue
for tool in sub.glob("*.pyd"):
# .pyd 文件名形如 mil.cp311-win_amd64.pyd
# 模块名取第一个点之前的部分(PyInit_ 后缀)
tool_name = _pyd_module_name(tool)
# 伴生包与 C 扩展不是主插件,跳过
if tool_name in ("openpyxl", "et_xmlfile") or tool_name.startswith("_"):
continue
try:
module = _load_module_from_path(tool_name, tool)
results.append({
"tool_name": tool_name,
"name": module.read_plugin_name(),
"version": module.read_plugin_version(),
"local_dir": str(sub),
})
except Exception as e:
logger.error(f"插件{tool_name}加载失败:{e}")
return results
@staticmethod
def _scan_remote_zips(plugins_dir: Path) -> list:
"""扫描远程 *.zip,读 zip 内 manifest.json 取插件元数据。
zip 文件名约定:<tool_name>-<version>-<python_tag>.zip
manifest.json 由 export_runtime.py 生成,含 plugin_name/version/description。
"""
results: list = []
for zip_file in plugins_dir.glob("*.zip"):
try:
with zipfile.ZipFile(zip_file, "r") as zf:
manifest = json.loads(zf.read("manifest.json"))
results.append({
"tool_name": manifest["tool_name"],
"name": manifest["plugin_name"],
"version": manifest["plugin_version"],
"description": manifest["plugin_description"],
"remote_path": str(zip_file),
})
except Exception as e:
logger.error(f"远程 zip {zip_file.name} 读取失败:{e}")
return results
class PluginRegistry(QObject):
"""插件注册中心。
主线程负责创建插件实例(create_plugin 返回 QWidget,必须在 GUI 线程创建);
发现与安装的 I/O 由 PluginWorker 在独立 QThread 中执行,结果通过信号回传
主线程。所有跨线程通信经 Qt 信号槽(Queued)。
"""
# --- 对外信号:通知主窗口 UI 更新 ---
plugins_loader_signal = Signal(dict)
update_plugins_card_signal = Signal(dict)
uninstall_completed_signal = Signal(str)
install_progress = Signal(str, int, int) # name, copied_bytes, total_bytes
install_finished = Signal(bool, str)
# --- 内部信号:主线程 → Worker 线程(跨线程请求) ---
_discover_local_requested = Signal(str)
_discover_remote_requested = Signal(str)
_install_requested = Signal(str, str)
_uninstall_requested = Signal(str, str)
_update_requested = Signal(str, str, str)
# 自重启后延迟启动,等待旧进程释放 .pyd 文件锁(毫秒)
_PENDING_CLEANUP_DELAY = 1500
def __init__(self, parent: Optional[QObject] = None) -> None:
super().__init__(parent)
self.plugins: dict = {}
self._installing_name: str = ""
self._last_logged_pct: int = 0 # 节流:记录上次上报的百分比
# Worker 在线程启动前创建(信号连接必须先于线程启动),
# 但实际启动延迟至清理完成后,避免新进程在 shutil.rmtree 之前
# 就把插件 .pyd 加载进内存导致自锁。
self._worker = PluginWorker()
self._thread = QThread()
self._worker.moveToThread(self._thread)
self._discover_local_requested.connect(self._worker.do_discover_local)
self._discover_remote_requested.connect(self._worker.do_discover_remote)
self._install_requested.connect(self._worker.do_install)
self._uninstall_requested.connect(self._worker.do_uninstall)
self._update_requested.connect(self._worker.do_update)
self._worker.local_discovered.connect(self._on_local_discovered)
self._worker.remote_discovered.connect(self._on_remote_discovered)
self._worker.install_finished.connect(self._on_install_finished)
self._worker.uninstall_finished.connect(self._on_uninstall_finished)
self._worker.install_progress.connect(self._on_install_progress)
self._thread.finished.connect(self._worker.deleteLater)
# 延迟启动:先等旧进程彻底退出并释放 .pyd 句柄,
# 再执行待删清单清理,最后才扫描并加载插件。
# 顺序必须是 清理 → 启动线程 → 发现插件,否则新进程自锁。
QTimer.singleShot(self._PENDING_CLEANUP_DELAY, self._post_init)
def _post_init(self) -> None:
"""延迟初始化:清理 → 启动线程 → 发现插件。
必须在旧进程退出后执行,且清理必须在任何插件 import 之前完成,
否则新进程自己持有 .pyd 文件锁导致 shutil.rmtree 失败。
"""
self._process_pending_uninstalls()
self._thread.start()
logger.info("插件系统初始化完成,开始发现插件")
QTimer.singleShot(0, self._start_discovery)
def shutdown(self) -> None:
"""优雅停止工作线程:请求事件循环退出并等待真正结束。
必须在 QThread 析构前调用,否则触发
"QThread: Destroyed while thread is still running" 警告。
"""
logger.info("PluginRegistry 正在关闭...")
self._thread.quit()
self._thread.wait(3000)
logger.info("PluginRegistry 已关闭")
@staticmethod
def _process_pending_uninstalls() -> None:
"""启动时执行待处理清单:删除插件目录,更新项另从远程 zip 解压。
清单行格式:
- ``<local_dir>`` 纯卸载,仅删除本地目录
- ``<local_dir>|<remote_zip>`` 更新,删除后从 remote_zip 解压新版本
必须在任何 importlib.import_module 之前调用,此时 .pyd 未被加载,
Windows 文件锁不会触发 WinError 5。但自重启场景下,原进程可能尚未
完全释放文件句柄,因此删除失败时保留清单行待下次处理。
"""
if not UNINSTALL_PENDING_FILE.exists():
return
logger.info("检测到待处理卸载/更新清单,开始清理...")
lines = UNINSTALL_PENDING_FILE.read_text(encoding='utf-8').splitlines()
failed: list = []
for line in lines:
line = line.strip()
if not line:
continue
parts = line.split('|', 1)
target = Path(parts[0])
# 纯卸载且目录已不存在视为已完成;更新项即使目录不存在也要继续解压
if not target.exists() and len(parts) == 1:
continue
# 删除整个插件目录(带重试:Windows 文件锁可能在短时间后释放)
deleted = False
last_error = None
for attempt in range(3):
try:
shutil.rmtree(target)
logger.info(f"已删除插件目录: {target}")
deleted = True
break
except Exception as e:
last_error = e
if attempt < 2:
time.sleep(0.5)
if not deleted:
failed.append(line)
logger.error(f"删除插件目录失败(可能仍被占用): {target} {last_error}")
continue
# 更新项:从远程 zip 解压新版本
if len(parts) == 2:
remote_zip = Path(parts[1])
try:
target.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(remote_zip, 'r') as zf:
zf.extractall(target)
logger.info(f"已更新插件: {target}")
except Exception as e:
failed.append(line)
logger.error(f"解压更新失败: {e}")
if failed:
UNINSTALL_PENDING_FILE.write_text("\n".join(failed) + "\n", encoding='utf-8')
else:
UNINSTALL_PENDING_FILE.unlink()
def _start_discovery(self) -> None:
"""启动插件发现:扫描本地 pyd 和远程 zip。"""
self._discover_local_requested.emit(str(LOCAL_PLUGINS_PATH))
self._discover_remote_requested.emit(str(REMOTE_PLUGINS_PATH))
@Slot(list)
def _on_local_discovered(self, results: list) -> None:
"""处理本地扫描结果:import_module + create_plugin,注册到 plugins 字典。
本地扫描在远程扫描之前完成,因此先发射 plugins_loader_signal
让主窗口把已安装插件加入工具栏,再发射 update_plugins_card_signal
刷新卡片列表(安装后 local version 已更新)。
"""
for item in results:
name = item["name"]
try:
module = importlib.import_module(item["tool_name"])
obj = module.create_plugin(WORKSPACE_PATH)
except Exception as e:
logger.error(f"本地插件 {name} 实例化失败,跳过: {e}")
continue
logger.info(f"本地插件已加载: {name} V{item['version']}")
if name not in self.plugins:
self.plugins[name] = {}
self.plugins[name]["obj"] = obj
self.plugins[name]["local version"] = item["version"]
self.plugins[name]["local_dir"] = item["local_dir"]
self.plugins_loader_signal.emit(self.plugins)
# 本地扫描完成后也刷新卡片(安装后 local version 更新,卡片需重绘状态)
self.update_plugins_card_signal.emit(self.plugins)
@Slot(list)
def _on_remote_discovered(self, results: list) -> None:
"""处理远程扫描结果:填充远程版本/描述/路径,刷新卡片列表。"""
for item in results:
name = item["name"]
if name not in self.plugins:
self.plugins[name] = {}
self.plugins[name]["local version"] = None
self.plugins[name]["local description"] = None
self.plugins[name]["tool_name"] = item["tool_name"]
self.plugins[name]["remote version"] = item["version"]
self.plugins[name]["remote description"] = item["description"]
self.plugins[name]["remote path"] = item["remote_path"]
self.update_plugins_card_signal.emit(self.plugins)
@Slot(bool, str)
def _on_install_finished(self, success: bool, message: str) -> None:
"""安装完成回调:成功则把新解压目录加入 sys.path 并重新扫描本地。"""
if success:
logger.info(message)
for sub in LOCAL_PLUGINS_PATH.iterdir():
if sub.is_dir() and str(sub) not in sys.path:
sys.path.insert(0, str(sub))
self._discover_local_requested.emit(str(LOCAL_PLUGINS_PATH))
else:
logger.error(message)
self._installing_name = ""
self.install_finished.emit(success, message)
@Slot(int, int)
def _on_install_progress(self, copied: int, total: int) -> None:
"""将安装进度写入日志(显示在 FrameLog),按百分比节流避免刷屏。"""
if not self._installing_name or total == 0:
return
pct = int(copied / total * 100)
if pct - self._last_logged_pct >= 10 or pct == 100:
mb_copied = copied / 1024 / 1024
mb_total = total / 1024 / 1024
logger.info(f"[{self._installing_name}] 安装进度: {pct}% ({mb_copied:.1f}/{mb_total:.1f} MB)")
self._last_logged_pct = pct
@Slot(str, bool, str)
def _on_uninstall_finished(self, name: str, success: bool, message: str) -> None:
"""卸载/更新完成回调:清除 plugins 字典中对应项的 obj 和版本,通知主窗口。"""
if not success:
logger.error(message)
return
logger.info(message)
info = self.plugins.get(name)
if info is not None:
info.pop("obj", None)
info["local version"] = None
self.uninstall_completed_signal.emit(name)
def start_plugins_event(self, event: Event, name: str) -> None:
"""本地子目录路径作为 install/uninstall/update 的目标。
本地布局:plugins/<tool_name>/zip 解压产物)
远程来源:plugins[name]['remote path'] 指向 .zip 文件
"""
tool_name = self.plugins[name]['tool_name']
local_dir = str(LOCAL_PLUGINS_PATH / tool_name)
if event == Event.Install:
self._installing_name = name
self._last_logged_pct = 0
logger.info(f"[{name}] 开始安装...")
remote_path = self.plugins[name]['remote path']
self._install_requested.emit(local_dir, remote_path)
elif event == Event.Uninstall:
logger.info(f"[{name}] 开始卸载...")
self._uninstall_requested.emit(name, local_dir)
elif event == Event.Update:
self._installing_name = name
logger.info(f"[{name}] 开始更新...")
remote_path = self.plugins[name]['remote path']
self._update_requested.emit(name, local_dir, remote_path)