- 新增 BaseWorker 通用 signals、取消标记和统一 run 入口 - 新增 run_worker QThread 包装与生命周期清理 - 新增 worker 单元测试覆盖 signal、取消、异常和类型校验 - 更新任务看板、API 合约、架构、当前状态和进度记录
128 lines
3.4 KiB
Python
128 lines
3.4 KiB
Python
"""PySide6 worker base classes and thread helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, Optional
|
|
|
|
|
|
try:
|
|
from PySide6.QtCore import QObject, QThread, Signal, Slot
|
|
|
|
QT_IMPORT_ERROR = None
|
|
except ModuleNotFoundError as exc:
|
|
QObject = object
|
|
QThread = None
|
|
QT_IMPORT_ERROR = exc
|
|
|
|
def Signal(*args, **kwargs): # type: ignore
|
|
return None
|
|
|
|
def Slot(*args, **kwargs): # type: ignore
|
|
def decorator(func):
|
|
return func
|
|
|
|
return decorator
|
|
|
|
|
|
class WorkerError(RuntimeError):
|
|
"""Raised when a worker cannot be started."""
|
|
|
|
|
|
def _require_qt() -> None:
|
|
if QT_IMPORT_ERROR is not None:
|
|
raise WorkerError("PySide6 未安装,无法启动后台任务")
|
|
|
|
|
|
def _summary_dict(value: Any) -> Dict[str, Any]:
|
|
if value is None:
|
|
return {}
|
|
if isinstance(value, dict):
|
|
return dict(value)
|
|
return {"result": value}
|
|
|
|
|
|
if QT_IMPORT_ERROR is None:
|
|
|
|
class BaseWorker(QObject):
|
|
"""Base QObject worker for long-running GUI tasks."""
|
|
|
|
progress = Signal(dict)
|
|
row_updated = Signal(int, dict)
|
|
log = Signal(str)
|
|
failed = Signal(int, str)
|
|
finished = Signal(dict)
|
|
cancelled = Signal(dict)
|
|
|
|
def __init__(self, parent: Optional[QObject] = None):
|
|
super().__init__(parent)
|
|
self._cancel_requested = False
|
|
|
|
def cancel(self) -> None:
|
|
self._cancel_requested = True
|
|
|
|
def is_cancelled(self) -> bool:
|
|
return self._cancel_requested
|
|
|
|
def should_cancel(self) -> bool:
|
|
return self.is_cancelled()
|
|
|
|
@Slot()
|
|
def run(self) -> None:
|
|
if self.is_cancelled():
|
|
self.cancelled.emit({"cancelled": True})
|
|
return
|
|
try:
|
|
summary = self.execute()
|
|
except Exception as exc: # pragma: no cover - covered through signal behavior
|
|
error = str(exc) or exc.__class__.__name__
|
|
self.failed.emit(-1, error)
|
|
self.finished.emit({"ok": False, "error": error})
|
|
return
|
|
|
|
payload = _summary_dict(summary)
|
|
if self.is_cancelled():
|
|
payload["cancelled"] = True
|
|
self.cancelled.emit(payload)
|
|
else:
|
|
self.finished.emit(payload)
|
|
|
|
def execute(self) -> Optional[Dict[str, Any]]:
|
|
raise NotImplementedError
|
|
|
|
else:
|
|
|
|
class BaseWorker(QObject): # type: ignore
|
|
def __init__(self, *args, **kwargs):
|
|
_require_qt()
|
|
|
|
|
|
def run_worker(
|
|
worker: BaseWorker,
|
|
thread_name: Optional[str] = None,
|
|
start: bool = True,
|
|
) -> QThread:
|
|
"""Move a worker to a QThread, wire lifecycle signals, and start it."""
|
|
|
|
_require_qt()
|
|
if not isinstance(worker, BaseWorker):
|
|
raise TypeError("worker 必须是 BaseWorker 实例")
|
|
|
|
thread = QThread()
|
|
if thread_name:
|
|
thread.setObjectName(thread_name)
|
|
|
|
worker.moveToThread(thread)
|
|
thread.started.connect(worker.run)
|
|
worker.finished.connect(thread.quit)
|
|
worker.cancelled.connect(thread.quit)
|
|
worker.finished.connect(worker.deleteLater)
|
|
worker.cancelled.connect(worker.deleteLater)
|
|
thread.finished.connect(thread.deleteLater)
|
|
|
|
# Keep the Python wrapper alive until Qt owns the lifecycle.
|
|
thread._cmshopee_worker = worker # type: ignore[attr-defined]
|
|
|
|
if start:
|
|
thread.start()
|
|
return thread
|