"""Run long operations off the GUI thread, with progress messages and cancel.""" import traceback from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal class Cancelled(Exception): pass class _Signals(QObject): done = Signal(object) failed = Signal(object, str) progress = Signal(str) class Job(QRunnable): """fn(job) -> result. Inside fn call job.report(msg) and job.check() (raises Cancelled).""" def __init__(self, fn): super().__init__() self.fn = fn self.signals = _Signals() self.cancelled = False def report(self, msg): self.check() self.signals.progress.emit(str(msg)) def check(self): if self.cancelled: raise Cancelled() def cancel(self): self.cancelled = True def run(self): try: r = self.fn(self) except Cancelled: self.signals.failed.emit(Cancelled(), "cancelled") except Exception as e: # noqa: BLE001 - shown to the user self.signals.failed.emit(e, traceback.format_exc()) else: self.signals.done.emit(r) class Runner: """Starts jobs on a thread pool (or inline when `sync`, used by the GUI tests).""" def __init__(self, sync=False): self.sync = sync self.pool = QThreadPool.globalInstance() self.active = set() # keep Python references until the queued result signal is delivered def start(self, fn, on_done=None, on_failed=None, on_progress=None): job = Job(fn) job.setAutoDelete(False) self.active.add(job) job.signals.done.connect(lambda *_: self.active.discard(job)) job.signals.failed.connect(lambda *_: self.active.discard(job)) if on_done: job.signals.done.connect(on_done) if on_failed: job.signals.failed.connect(on_failed) if on_progress: job.signals.progress.connect(on_progress) if self.sync: job.run() else: self.pool.start(job) return job