lightfall-utils 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1071 @@
1
+ """Qt-integrated threading utilities.
2
+
3
+ Provides Qt-integrated threading with global thread management, flexible decorators,
4
+ and proper interruption/cancellation support.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ import threading
11
+ import time
12
+ import traceback
13
+ from collections.abc import Callable, Generator
14
+ from functools import wraps
15
+ from typing import TYPE_CHECKING, Any, TypeVar
16
+
17
+ from PySide6.QtCore import QCoreApplication, QEvent, QObject, QThread, QTimer, Signal
18
+ from PySide6.QtWidgets import QApplication
19
+
20
+ from lightfall_utils.logging import logger
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Callable
24
+
25
+ __all__ = [
26
+ "ThreadManager",
27
+ "ManagedThreadPool",
28
+ "get_thread_manager",
29
+ "thread_manager", # noqa: F822 — provided lazily via module __getattr__ (PEP 562)
30
+ "QThreadFuture",
31
+ "QThreadFutureIterator",
32
+ "method",
33
+ "iterator",
34
+ "invoke_in_main_thread",
35
+ "invoke_as_event",
36
+ "is_main_thread",
37
+ "initialize_main_thread_invoker",
38
+ ]
39
+
40
+ T = TypeVar("T")
41
+
42
+ # -----------------------------------------------------------------------------
43
+ # Coverage workaround for QThread tracing
44
+ # -----------------------------------------------------------------------------
45
+ _running_coverage = "coverage" in sys.modules
46
+
47
+
48
+ def _coverage_resolve_trace(fn: Callable[..., T]) -> Callable[..., T]:
49
+ """Decorator to fix coverage tracing inside QThread.run() methods."""
50
+ if not _running_coverage:
51
+ return fn
52
+
53
+ @wraps(fn)
54
+ def wrapped(*args: Any, **kwargs: Any) -> T:
55
+ sys.settrace(threading._trace_hook)
56
+ return fn(*args, **kwargs)
57
+
58
+ return wrapped
59
+
60
+
61
+ # -----------------------------------------------------------------------------
62
+ # Thread Manager (Singleton)
63
+ # -----------------------------------------------------------------------------
64
+ class _ThreadManagerSignals(QObject):
65
+ """Helper QObject to hold signals for ThreadManager (which is not a QObject)."""
66
+
67
+ sigProgress = Signal(object, object, object, object) # (thread, current, minimum, maximum)
68
+ sigFinished = Signal(object) # (thread,)
69
+
70
+
71
+ class ThreadManager:
72
+ """Global registry for tracking and managing QThreadFuture instances.
73
+
74
+ Access via get_thread_manager() or the module-level thread_manager variable.
75
+
76
+ Signals (via .signals attribute):
77
+ sigProgress: Relayed from all registered threads.
78
+ """
79
+
80
+ _instance: ThreadManager | None = None
81
+ _lock = threading.Lock()
82
+
83
+ def __new__(cls) -> ThreadManager:
84
+ if cls._instance is None:
85
+ with cls._lock:
86
+ if cls._instance is None:
87
+ cls._instance = super().__new__(cls)
88
+ cls._instance._initialized = False
89
+ return cls._instance
90
+
91
+ def __init__(self) -> None:
92
+ if self._initialized:
93
+ return
94
+ self._initialized = True
95
+ self._signals = _ThreadManagerSignals()
96
+ # Strong references to prevent GC before thread completion.
97
+ # Threads unregister themselves in finally block when done.
98
+ self._threads: dict[int, QThreadFuture] = {}
99
+ self._keys: dict[str, int] = {} # key -> thread id mapping
100
+ self._registry_lock = threading.Lock()
101
+ self._shutdown_connected = False
102
+ self._connect_app_shutdown()
103
+
104
+ @property
105
+ def sigProgress(self) -> Signal:
106
+ """Progress signal relayed from all registered threads."""
107
+ return self._signals.sigProgress
108
+
109
+ @property
110
+ def sigFinished(self) -> Signal:
111
+ """Emitted with the thread object when a registered thread finishes."""
112
+ return self._signals.sigFinished
113
+
114
+ def _connect_app_shutdown(self) -> None:
115
+ """Connect to application shutdown signal if app exists."""
116
+ if self._shutdown_connected:
117
+ return
118
+ app = QApplication.instance()
119
+ if app:
120
+ app.aboutToQuit.connect(self.shutdown)
121
+ self._shutdown_connected = True
122
+
123
+ def register(self, thread: QThreadFuture, key: str | None = None) -> None:
124
+ """Register a thread for tracking.
125
+
126
+ Holds a strong reference to prevent GC before thread completion.
127
+ Threads must call unregister() when done (handled automatically
128
+ in QThreadFuture.run() finally block).
129
+
130
+ Args:
131
+ thread: The QThreadFuture to track.
132
+ key: Optional unique key for later retrieval. If a thread with
133
+ this key already exists, the old one is cancelled first.
134
+ """
135
+ # Ensure shutdown is connected (in case app was created after ThreadManager)
136
+ self._connect_app_shutdown()
137
+
138
+ thread_id = id(thread)
139
+
140
+ with self._registry_lock:
141
+ # Cancel existing thread with same key
142
+ if key and key in self._keys:
143
+ old_id = self._keys[key]
144
+ old_thread = self._threads.get(old_id)
145
+ if old_thread and old_thread.isRunning():
146
+ logger.debug(f"Cancelling existing thread with key '{key}'")
147
+ old_thread.cancel()
148
+
149
+ self._threads[thread_id] = thread
150
+ if key:
151
+ self._keys[key] = thread_id
152
+ thread._manager_key = key
153
+
154
+ # Relay thread progress through the manager's signal
155
+ thread.sigProgress.connect(self._signals.sigProgress)
156
+
157
+ logger.trace(f"Registered thread {thread_id}" + (f" with key '{key}'" if key else ""))
158
+
159
+ def unregister(self, thread: QThreadFuture) -> None:
160
+ """Unregister a thread from tracking."""
161
+ thread_id = id(thread)
162
+ with self._registry_lock:
163
+ self._threads.pop(thread_id, None)
164
+ key = getattr(thread, "_manager_key", None)
165
+ if key and key in self._keys:
166
+ del self._keys[key]
167
+
168
+ # Disconnect progress relay
169
+ try:
170
+ thread.sigProgress.disconnect(self._signals.sigProgress)
171
+ except RuntimeError:
172
+ pass # Already disconnected
173
+
174
+ self._signals.sigFinished.emit(thread)
175
+ logger.trace(f"Unregistered thread {thread_id}")
176
+
177
+ def get_active(self) -> list[QThreadFuture]:
178
+ """Get all currently active (running) threads."""
179
+ active = []
180
+ with self._registry_lock:
181
+ for thread in list(self._threads.values()):
182
+ if thread.isRunning():
183
+ active.append(thread)
184
+ return active
185
+
186
+ def get_by_key(self, key: str) -> QThreadFuture | None:
187
+ """Get a thread by its key."""
188
+ with self._registry_lock:
189
+ thread_id = self._keys.get(key)
190
+ if thread_id is None:
191
+ return None
192
+ return self._threads.get(thread_id)
193
+
194
+ def cancel(self, key: str, timeout_ms: int = 5000) -> bool:
195
+ """Cancel a thread by key.
196
+
197
+ Args:
198
+ key: The thread key.
199
+ timeout_ms: Time to wait for graceful shutdown before force-terminating.
200
+
201
+ Returns:
202
+ True if thread was found and cancelled, False if not found.
203
+ """
204
+ thread = self.get_by_key(key)
205
+ if thread is None:
206
+ return False
207
+ thread.cancel(timeout_ms=timeout_ms)
208
+ return True
209
+
210
+ def cancel_all(self, timeout_ms: int = 5000) -> None:
211
+ """Cancel all active threads.
212
+
213
+ Args:
214
+ timeout_ms: Time to wait for each thread's graceful shutdown.
215
+ """
216
+ active = self.get_active()
217
+ logger.debug(f"Cancelling {len(active)} active thread(s)")
218
+ for thread in active:
219
+ thread.cancel(timeout_ms=timeout_ms)
220
+
221
+ def wait_all(self, timeout_ms: int | None = None) -> bool:
222
+ """Wait for all active threads to complete.
223
+
224
+ Args:
225
+ timeout_ms: Maximum time to wait in milliseconds. None for indefinite.
226
+
227
+ Returns:
228
+ True if all threads finished, False if timeout occurred.
229
+ """
230
+ active = self.get_active()
231
+ if not active:
232
+ return True
233
+
234
+ deadline = None
235
+ if timeout_ms is not None:
236
+ deadline = time.monotonic() + timeout_ms / 1000
237
+
238
+ for thread in active:
239
+ if deadline is not None:
240
+ remaining = deadline - time.monotonic()
241
+ if remaining <= 0:
242
+ return False
243
+ if not thread.wait(int(remaining * 1000)):
244
+ return False
245
+ else:
246
+ thread.wait()
247
+
248
+ return True
249
+
250
+ def shutdown(self) -> None:
251
+ """Shutdown all threads and pools. Called automatically on application quit."""
252
+ logger.debug("ThreadManager shutting down")
253
+ self.cancel_all(timeout_ms=3000)
254
+ ManagedThreadPool.shutdown_all(wait=False)
255
+
256
+
257
+ def get_thread_manager() -> ThreadManager:
258
+ """Get the global ThreadManager instance."""
259
+ return ThreadManager()
260
+
261
+
262
+ def __getattr__(name: str) -> Any:
263
+ """Lazy module attribute (PEP 562).
264
+
265
+ ``thread_manager`` is created on first access rather than at import
266
+ time: constructing ThreadManager creates a QObject, and importing this
267
+ module must stay safe before QApplication exists.
268
+ """
269
+ if name == "thread_manager":
270
+ return get_thread_manager()
271
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
272
+
273
+
274
+ # -----------------------------------------------------------------------------
275
+ # Managed Thread Pool
276
+ # -----------------------------------------------------------------------------
277
+ class ManagedThreadPool:
278
+ """A lightweight thread pool with daemon threads and lifecycle management.
279
+
280
+ Unlike ``ThreadPoolExecutor``, this creates daemon threads directly —
281
+ no monkey-patching of CPython internals, no conflicts with Sentry's
282
+ threading integration, and no non-daemon threads blocking process exit.
283
+
284
+ The pool registers itself with ``ThreadManager`` for automatic shutdown
285
+ on application quit.
286
+
287
+ Usage::
288
+
289
+ pool = ManagedThreadPool(max_workers=2, name="my-pool")
290
+ future = pool.submit(some_function, arg1, arg2)
291
+
292
+ # Pool shuts down automatically on app quit, or manually:
293
+ pool.shutdown()
294
+
295
+ Args:
296
+ max_workers: Maximum number of concurrent threads.
297
+ name: Name prefix for threads (used in logging and thread names).
298
+ """
299
+
300
+ # Class-level registry for all pools, so ThreadManager can shut them all down
301
+ _all_pools: list[ManagedThreadPool] = []
302
+ _pools_lock = threading.Lock()
303
+
304
+ def __init__(self, max_workers: int = 1, name: str = "managed-pool") -> None:
305
+ import queue as _queue
306
+ from concurrent.futures import Future
307
+
308
+ self._name = name
309
+ self._shutdown_flag = False
310
+ self._lock = threading.Lock()
311
+ self._work_queue: _queue.SimpleQueue[tuple[Callable, Future] | None] = _queue.SimpleQueue()
312
+ self._Future = Future
313
+
314
+ # Spin up daemon worker threads
315
+ self._workers: list[threading.Thread] = []
316
+ for i in range(max_workers):
317
+ t = threading.Thread(
318
+ target=self._worker,
319
+ daemon=True,
320
+ name=f"{name}_{i}",
321
+ )
322
+ t.start()
323
+ self._workers.append(t)
324
+
325
+ # Register for global cleanup
326
+ with ManagedThreadPool._pools_lock:
327
+ ManagedThreadPool._all_pools.append(self)
328
+
329
+ logger.debug("ManagedThreadPool '{}' created (max_workers={})", name, max_workers)
330
+
331
+ def _worker(self) -> None:
332
+ """Worker loop: pull tasks from the queue and execute them."""
333
+ while True:
334
+ item = self._work_queue.get()
335
+ if item is None:
336
+ # Poison pill — shut down this worker
337
+ return
338
+ fn, future = item
339
+ if future.cancelled():
340
+ continue
341
+ try:
342
+ result = fn()
343
+ future.set_result(result)
344
+ except Exception as exc:
345
+ future.set_exception(exc)
346
+
347
+ @property
348
+ def name(self) -> str:
349
+ """Pool name."""
350
+ return self._name
351
+
352
+ def submit(self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> Any:
353
+ """Submit a callable for execution.
354
+
355
+ Args:
356
+ fn: The callable to execute.
357
+ *args: Positional arguments for the callable.
358
+ **kwargs: Keyword arguments for the callable.
359
+
360
+ Returns:
361
+ A ``concurrent.futures.Future`` representing the result.
362
+
363
+ Raises:
364
+ RuntimeError: If the pool has been shut down.
365
+ """
366
+ if self._shutdown_flag:
367
+ raise RuntimeError(f"ManagedThreadPool '{self._name}' is shut down")
368
+ future = self._Future()
369
+ # Bind args/kwargs into a no-arg callable
370
+ self._work_queue.put((lambda: fn(*args, **kwargs), future))
371
+ return future
372
+
373
+ def shutdown(self, wait: bool = False) -> None:
374
+ """Shut down the pool.
375
+
376
+ Args:
377
+ wait: If True, block until all worker threads finish.
378
+ Defaults to False (non-blocking) since threads are daemon.
379
+ """
380
+ with self._lock:
381
+ if self._shutdown_flag:
382
+ return
383
+ self._shutdown_flag = True
384
+
385
+ # Send poison pills to each worker
386
+ for _ in self._workers:
387
+ self._work_queue.put(None)
388
+
389
+ if wait:
390
+ for t in self._workers:
391
+ t.join(timeout=5.0)
392
+
393
+ # Remove from global registry
394
+ with ManagedThreadPool._pools_lock:
395
+ try:
396
+ ManagedThreadPool._all_pools.remove(self)
397
+ except ValueError:
398
+ pass
399
+
400
+ logger.debug("ManagedThreadPool '{}' shut down", self._name)
401
+
402
+ @classmethod
403
+ def shutdown_all(cls, wait: bool = False) -> None:
404
+ """Shut down all managed thread pools.
405
+
406
+ Called automatically by ThreadManager during application shutdown.
407
+
408
+ Args:
409
+ wait: If True, block until all running tasks complete.
410
+ """
411
+ with cls._pools_lock:
412
+ pools = list(cls._all_pools)
413
+
414
+ for pool in pools:
415
+ try:
416
+ pool.shutdown(wait=wait)
417
+ except Exception as exc:
418
+ logger.debug("Error shutting down pool '{}': {}", pool._name, exc)
419
+
420
+ def __del__(self) -> None:
421
+ """Clean up on garbage collection."""
422
+ if not self._shutdown_flag:
423
+ try:
424
+ self.shutdown(wait=False)
425
+ except Exception:
426
+ pass
427
+
428
+
429
+ # -----------------------------------------------------------------------------
430
+ # QThreadFuture
431
+ # -----------------------------------------------------------------------------
432
+ class QThreadFuture(QThread):
433
+ """A future-like QThread with automatic registration and improved cancellation.
434
+
435
+ Uses Qt signals for cross-thread callback delivery, which is more robust
436
+ than invoke_in_main_thread() as it doesn't require careful initialization
437
+ timing of the invoker object.
438
+
439
+ Signals:
440
+ sigResult: Emitted with the return value when method completes.
441
+ sigError: Emitted with the exception when an error occurs.
442
+ sigDone: Emitted when thread finishes successfully (after sigResult).
443
+
444
+ Example:
445
+ def long_task(x):
446
+ time.sleep(1)
447
+ return x * 2
448
+
449
+ future = QThreadFuture(long_task, 5, callback_slot=print)
450
+ future.start()
451
+ # Later: print receives 10
452
+ """
453
+
454
+ # Signals for cross-thread callback delivery
455
+ # Qt handles thread marshalling automatically when these are emitted
456
+ sigResult = Signal(object) # Emitted with return value
457
+ sigError = Signal(object) # Emitted with exception
458
+ sigDone = Signal() # Emitted when finished successfully
459
+ sigProgress = Signal(object, object, object, object) # (thread, current, minimum, maximum)
460
+
461
+ def __init__(
462
+ self,
463
+ method: Callable[..., Any],
464
+ *args: Any,
465
+ callback_slot: Callable[..., Any] | None = None,
466
+ finished_slot: Callable[[], None] | None = None,
467
+ except_slot: Callable[[Exception], None] | None = None,
468
+ progress_slot: Callable[..., Any] | None = None,
469
+ interrupt_callable: Callable[[], None] | None = None,
470
+ priority: QThread.Priority = QThread.Priority.InheritPriority,
471
+ timeout: int = 0,
472
+ key: str | None = None,
473
+ name: str | None = None,
474
+ register: bool = True,
475
+ log_exceptions: bool = True,
476
+ **kwargs: Any,
477
+ ) -> None:
478
+ """Initialize the thread future.
479
+
480
+ Args:
481
+ method: The callable to run in the background.
482
+ *args: Positional arguments for the method.
483
+ callback_slot: Called with the return value(s) when method completes.
484
+ finished_slot: Called (no args) when thread finishes successfully.
485
+ except_slot: Called with exception if an error occurs.
486
+ progress_slot: Called with (thread, current, minimum, maximum) on progress.
487
+ interrupt_callable: Called when interrupt is requested (for custom cleanup).
488
+ priority: Thread priority.
489
+ timeout: Auto-cancel after this many milliseconds (0 = no timeout).
490
+ key: Unique key for ThreadManager lookup. Threads with duplicate keys
491
+ will cancel the previous thread.
492
+ name: Name for the thread (for debugging).
493
+ register: Whether to register with ThreadManager (default True).
494
+ log_exceptions: Whether the framework logs an uncaught worker
495
+ exception at ERROR with a full traceback (default True). Set
496
+ False when ``except_slot`` fully owns error reporting (e.g. a
497
+ periodic poller that dedupes/rate-limits its own failures) —
498
+ the exception is still stored and delivered to ``except_slot``,
499
+ but the framework emits only a single concise DEBUG line
500
+ instead of a repeated ERROR + traceback.
501
+ **kwargs: Keyword arguments for the method.
502
+ """
503
+ super().__init__()
504
+
505
+ self._method = method
506
+ self._args = args
507
+ self._kwargs = kwargs
508
+ self._callback_slot = callback_slot
509
+ self._finished_slot = finished_slot
510
+ self._except_slot = except_slot
511
+ self._progress_slot = progress_slot
512
+ self._interrupt_callable = interrupt_callable
513
+ self._priority = priority
514
+ self._timeout = timeout
515
+ self._key = key
516
+ self._name = name or getattr(method, "__name__", "anonymous")
517
+ self._register = register
518
+ self._log_exceptions = log_exceptions
519
+
520
+ self._cancelled = False
521
+ self._exception: Exception | None = None
522
+ self._result: Any = None
523
+ self._manager_key: str | None = None
524
+
525
+ # Connect user-provided slots to signals
526
+ # Qt's signal/slot mechanism handles cross-thread marshalling automatically
527
+ if callback_slot:
528
+ self.sigResult.connect(callback_slot)
529
+ if except_slot:
530
+ self.sigError.connect(except_slot)
531
+ if finished_slot:
532
+ self.sigDone.connect(finished_slot)
533
+ if progress_slot:
534
+ self.sigProgress.connect(progress_slot)
535
+
536
+ @property
537
+ def cancelled(self) -> bool:
538
+ """Whether the thread was cancelled."""
539
+ return self._cancelled
540
+
541
+ @property
542
+ def exception(self) -> Exception | None:
543
+ """The exception raised during execution, if any."""
544
+ return self._exception
545
+
546
+ @property
547
+ def done(self) -> bool:
548
+ """Whether the thread has finished."""
549
+ return self.isFinished()
550
+
551
+ @property
552
+ def running(self) -> bool:
553
+ """Whether the thread is currently running."""
554
+ return self.isRunning()
555
+
556
+ def start(self) -> None:
557
+ """Start the thread."""
558
+ if self.running:
559
+ raise RuntimeError("Thread is already running")
560
+
561
+ if self._register:
562
+ get_thread_manager().register(self, self._key)
563
+ # Unregister when the thread finishes. The finished signal is
564
+ # emitted by QThread's C++ internals AFTER run() returns, so
565
+ # the thread is in a safe state. This is more reliable than
566
+ # calling unregister inside run()'s finally block, because:
567
+ # 1. run() is still on the call stack when finally runs
568
+ # 2. invoke_in_main_thread uses a Python QEvent subclass whose
569
+ # wrapper can be GC'd after postEvent transfers C++ ownership
570
+ self.finished.connect(self._deferred_unregister)
571
+
572
+ super().start(self._priority)
573
+
574
+ if self._timeout > 0:
575
+ QTimer.singleShot(self._timeout, self.cancel)
576
+
577
+ def _deferred_unregister(self) -> None:
578
+ """Unregister from ThreadManager after thread has finished.
579
+
580
+ Connected to the finished signal in start(). The finished signal
581
+ is emitted by QThread's C++ internals after run() has fully
582
+ returned, so it's safe to drop the ThreadManager reference here.
583
+ """
584
+ get_thread_manager().unregister(self)
585
+
586
+ @_coverage_resolve_trace
587
+ def run(self) -> None:
588
+ """Execute the method. Do not call directly; use start()."""
589
+ threading.current_thread().name = self._name
590
+ self._cancelled = False
591
+ self._exception = None
592
+
593
+ try:
594
+ runner = self._run()
595
+ while not self.isInterruptionRequested():
596
+ try:
597
+ value = next(runner)
598
+ except StopIteration as ex:
599
+ value = ex.value
600
+ self._result = value
601
+ if self._callback_slot:
602
+ self.sigResult.emit(value)
603
+ break
604
+
605
+ # For regular QThreadFuture, emit result on each yield
606
+ if not isinstance(self, QThreadFutureIterator) and self._callback_slot:
607
+ self.sigResult.emit(value)
608
+
609
+ except Exception as ex:
610
+ self._exception = ex
611
+ if self._log_exceptions:
612
+ try:
613
+ args_repr = repr(self._args)
614
+ except Exception:
615
+ args_repr = f"<{len(self._args)} args, repr failed>"
616
+ try:
617
+ kwargs_repr = repr(self._kwargs)
618
+ except Exception:
619
+ kwargs_repr = f"<{len(self._kwargs)} kwargs, repr failed>"
620
+ logger.error(
621
+ f"Error in thread '{self._name}': {ex}\n"
622
+ f"Method: {getattr(self._method, '__name__', 'UNKNOWN')}\n"
623
+ f"Args: {args_repr}\n"
624
+ f"Kwargs: {kwargs_repr}"
625
+ )
626
+ logger.exception(ex)
627
+ else:
628
+ # Caller's except_slot owns error reporting; keep a single
629
+ # concise DEBUG breadcrumb instead of a repeated ERROR+traceback.
630
+ logger.debug(
631
+ "Thread '{}' raised {} (delivered to except_slot)",
632
+ self._name,
633
+ type(ex).__name__,
634
+ )
635
+ if self._except_slot:
636
+ self.sigError.emit(ex)
637
+ else:
638
+ if self._finished_slot:
639
+ self.sigDone.emit()
640
+
641
+ def _run(self) -> Generator[Any, None, Any]:
642
+ """Internal run implementation. Override in subclasses.
643
+
644
+ For regular QThreadFuture, returns the method result via StopIteration
645
+ so the callback is only invoked once. QThreadFutureIterator overrides
646
+ this to yield intermediate values.
647
+ """
648
+ return self._method(*self._args, **self._kwargs)
649
+ yield # Makes this a generator function (never reached)
650
+
651
+ def result(self, timeout_ms: int | None = None) -> Any:
652
+ """Wait for and return the result.
653
+
654
+ Args:
655
+ timeout_ms: Maximum time to wait. None for indefinite.
656
+
657
+ Returns:
658
+ The return value of the method, or the exception if one occurred.
659
+
660
+ Raises:
661
+ TimeoutError: If timeout is exceeded.
662
+ """
663
+ if not self.running and not self.done:
664
+ self.start()
665
+
666
+ if timeout_ms is not None:
667
+ if not self.wait(timeout_ms):
668
+ raise TimeoutError(f"Thread did not complete within {timeout_ms}ms")
669
+ else:
670
+ self.wait()
671
+
672
+ if self._exception:
673
+ raise self._exception
674
+ return self._result
675
+
676
+ def cancel(self, timeout_ms: int = 5000) -> bool:
677
+ """Cancel the thread, gracefully.
678
+
679
+ Requests interruption (and invokes any ``interrupt_callable``), then
680
+ waits up to ``timeout_ms`` for the thread to stop cooperatively.
681
+
682
+ A thread that ignores the interruption request is **abandoned**, not
683
+ force-killed: ``QThread.terminate()`` aborts a thread at an arbitrary
684
+ machine instruction, and if that thread is executing Python (holding
685
+ the GIL, mid-allocation, inside a C extension) it corrupts the
686
+ interpreter heap and crashes the whole process with an access
687
+ violation (0xC0000005) — often later, on an unrelated thread. A stuck
688
+ thread is the lesser evil, so we leave it running (it keeps its
689
+ ThreadManager reference and is reclaimed when it finally unblocks or
690
+ the process exits). Give long-blocking work an ``interrupt_callable``
691
+ that unblocks its wait so cancellation stays prompt.
692
+
693
+ Args:
694
+ timeout_ms: Time to wait for graceful shutdown.
695
+
696
+ Returns:
697
+ True if the thread stopped, False if it ignored interruption and
698
+ was abandoned.
699
+ """
700
+ if not self.running:
701
+ return True
702
+
703
+ self._cancelled = True
704
+ self.requestInterruption()
705
+
706
+ if self._interrupt_callable:
707
+ try:
708
+ self._interrupt_callable()
709
+ except Exception as ex:
710
+ logger.warning(f"Error in interrupt callable: {ex}")
711
+
712
+ # Wait for graceful shutdown
713
+ if self.wait(timeout_ms):
714
+ logger.debug(f"Thread '{self._name}' stopped gracefully")
715
+ return True
716
+
717
+ # The thread ignored the interruption request. We deliberately do NOT
718
+ # call terminate() here (see the docstring): abandon it instead.
719
+ logger.warning(
720
+ "Thread '{}' ignored interruption request after {} ms; abandoning "
721
+ "it rather than force-terminating (terminate() would risk "
722
+ "corrupting the interpreter and crashing the process). If this "
723
+ "thread blocks on I/O, give it an interrupt_callable that unblocks "
724
+ "the wait so cancellation stays prompt.",
725
+ self._name,
726
+ timeout_ms,
727
+ )
728
+ return False
729
+
730
+ def terminate(self) -> None:
731
+ """Forcibly terminate the thread — DANGEROUS, logs the antipattern.
732
+
733
+ ``QThread.terminate()`` aborts the thread at an arbitrary machine
734
+ instruction. If the thread is executing Python (holding the GIL,
735
+ mid-allocation, inside a C extension) the interpreter heap is left
736
+ corrupted and the process typically dies with an access violation
737
+ (0xC0000005), frequently later and on an unrelated thread — a crash
738
+ that is extremely hard to trace back here.
739
+
740
+ Well-behaved applications never call this as part of normal cancellation (see
741
+ ``cancel()``). It is retained only so that a deliberate caller — or
742
+ Qt internals — that reaches it is loudly flagged in the logs.
743
+ """
744
+ logger.warning(
745
+ "QThread.terminate() called on thread '{}' — this is an "
746
+ "ANTIPATTERN: terminating a thread that is executing Python can "
747
+ "corrupt the interpreter heap and crash the process (0xC0000005). "
748
+ "Prefer graceful cancellation via requestInterruption() / an "
749
+ "interrupt_callable.\nCall site:\n{}",
750
+ self._name,
751
+ "".join(traceback.format_stack()[:-1]),
752
+ )
753
+ super().terminate()
754
+
755
+ def report_progress(self, current: float, minimum: float = 0, maximum: float = 100) -> None:
756
+ """Report progress from within the running thread.
757
+
758
+ Can be called from the thread's method via
759
+ ``QThread.currentThread().report_progress(current, minimum, maximum)``.
760
+
761
+ Args:
762
+ current: Current progress value.
763
+ minimum: Minimum progress value.
764
+ maximum: Maximum progress value.
765
+ """
766
+ self.sigProgress.emit(self, current, minimum, maximum)
767
+
768
+ def interrupt(self) -> None:
769
+ """Request interruption without waiting."""
770
+ self.requestInterruption()
771
+ if self._interrupt_callable:
772
+ self._interrupt_callable()
773
+
774
+ def __enter__(self) -> QThreadFuture:
775
+ """Context manager entry - starts the thread."""
776
+ self.start()
777
+ return self
778
+
779
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
780
+ """Context manager exit - waits for completion."""
781
+ self.wait()
782
+
783
+
784
+ # -----------------------------------------------------------------------------
785
+ # QThreadFutureIterator
786
+ # -----------------------------------------------------------------------------
787
+ class QThreadFutureIterator(QThreadFuture):
788
+ """QThreadFuture variant for generators that yields intermediate values.
789
+
790
+ The yield_slot is called for each yielded value, while callback_slot
791
+ is called with the final return value.
792
+
793
+ Signals:
794
+ sigYield: Emitted with each yielded value from the generator.
795
+ """
796
+
797
+ # Signal for yielded values (separate from sigResult which is for final value)
798
+ sigYield = Signal(object)
799
+
800
+ def __init__(
801
+ self,
802
+ method: Callable[..., Generator[Any, None, Any]],
803
+ *args: Any,
804
+ yield_slot: Callable[..., Any] | None = None,
805
+ **kwargs: Any,
806
+ ) -> None:
807
+ """Initialize the iterator thread.
808
+
809
+ Args:
810
+ method: A generator function.
811
+ *args: Positional arguments for the generator.
812
+ yield_slot: Called with each yielded value.
813
+ **kwargs: Additional arguments (see QThreadFuture).
814
+ """
815
+ super().__init__(method, *args, **kwargs)
816
+ self._yield_slot = yield_slot
817
+
818
+ # Connect yield_slot to sigYield signal
819
+ if yield_slot:
820
+ self.sigYield.connect(yield_slot)
821
+
822
+ def _run(self) -> Generator[Any, None, Any]:
823
+ """Run the generator, yielding each value."""
824
+ gen = self._method(*self._args, **self._kwargs)
825
+ for value in gen:
826
+ if self.isInterruptionRequested():
827
+ return
828
+ if self._yield_slot:
829
+ self.sigYield.emit(value)
830
+ yield value
831
+
832
+
833
+ # -----------------------------------------------------------------------------
834
+ # Main Thread Invocation
835
+ # -----------------------------------------------------------------------------
836
+ # Lazy-initialized to avoid creating Qt objects before QApplication exists.
837
+ # On Windows, creating QObjects before QApplication can cause crashes.
838
+ _invoke_event_type: QEvent.Type | None = None
839
+ _invoker: _Invoker | None = None
840
+ _invoker_lock = threading.Lock()
841
+
842
+
843
+ def _get_invoke_event_type() -> QEvent.Type:
844
+ """Get or create the custom event type (lazy initialization)."""
845
+ global _invoke_event_type
846
+ if _invoke_event_type is None:
847
+ _invoke_event_type = QEvent.Type(QEvent.registerEventType())
848
+ return _invoke_event_type
849
+
850
+
851
+ def _get_invoker() -> _Invoker:
852
+ """Get or create the invoker singleton (lazy initialization)."""
853
+ global _invoker
854
+ if _invoker is None:
855
+ with _invoker_lock:
856
+ if _invoker is None:
857
+ _invoker = _Invoker()
858
+ return _invoker
859
+
860
+
861
+ def initialize_main_thread_invoker() -> None:
862
+ """Initialize the invoker on the main thread.
863
+
864
+ Call this after QApplication is created but before starting any
865
+ background threads that use invoke_in_main_thread().
866
+
867
+ This is required because the invoker (a QObject) must be created
868
+ on the main thread for proper event delivery.
869
+ """
870
+ if not is_main_thread():
871
+ raise RuntimeError("initialize_main_thread_invoker must be called from main thread")
872
+ _get_invoker()
873
+ _get_invoke_event_type()
874
+
875
+
876
+ class _InvokeEvent(QEvent):
877
+ """QEvent that carries a callable for main thread execution."""
878
+
879
+ def __init__(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
880
+ super().__init__(_get_invoke_event_type())
881
+ self.fn = fn
882
+ self.args = args
883
+ self.kwargs = kwargs
884
+
885
+
886
+ class _Invoker(QObject):
887
+ """QObject that processes InvokeEvents in the main thread."""
888
+
889
+ def event(self, event: QEvent) -> bool:
890
+ if isinstance(event, _InvokeEvent):
891
+ try:
892
+ # Check if it's a signal (has emit method)
893
+ if hasattr(event.fn, "emit"):
894
+ event.fn.emit(*event.args)
895
+ else:
896
+ event.fn(*event.args, **event.kwargs)
897
+ except Exception as ex:
898
+ logger.error(f"Error invoking callback in main thread: {ex}")
899
+ logger.exception(ex)
900
+ return True
901
+ return super().event(event)
902
+
903
+
904
+ def invoke_in_main_thread(
905
+ fn: Callable[..., Any], *args: Any, force_event: bool = False, **kwargs: Any
906
+ ) -> None:
907
+ """Invoke a callable in the main thread.
908
+
909
+ If already in the main thread and force_event is False, calls immediately.
910
+ Otherwise posts an event to be processed in the main thread's event loop.
911
+
912
+ Args:
913
+ fn: The callable to invoke.
914
+ *args: Positional arguments.
915
+ force_event: If True, always post as event even if in main thread.
916
+ **kwargs: Keyword arguments.
917
+ """
918
+ if not force_event and is_main_thread():
919
+ fn(*args, **kwargs)
920
+ else:
921
+ QCoreApplication.postEvent(_get_invoker(), _InvokeEvent(fn, *args, **kwargs))
922
+
923
+
924
+ def invoke_as_event(fn: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
925
+ """Invoke a callable as an event in the main thread (always posts event)."""
926
+ invoke_in_main_thread(fn, *args, force_event=True, **kwargs)
927
+
928
+
929
+ def is_main_thread() -> bool:
930
+ """Check if the current thread is the main thread."""
931
+ return threading.current_thread() is threading.main_thread()
932
+
933
+
934
+ # -----------------------------------------------------------------------------
935
+ # Decorators
936
+ # -----------------------------------------------------------------------------
937
+ def method(
938
+ callback_slot: Callable[..., Any] | None = None,
939
+ finished_slot: Callable[[], None] | None = None,
940
+ except_slot: Callable[[Exception], None] | None = None,
941
+ priority: QThread.Priority = QThread.Priority.InheritPriority,
942
+ timeout: int = 0,
943
+ block: bool = False,
944
+ key: str | None = None,
945
+ name: str | None = None,
946
+ ) -> Callable[[Callable[..., T]], Callable[..., QThreadFuture]]:
947
+ """Decorator to run a function on a background thread.
948
+
949
+ The decorated function returns a QThreadFuture that starts immediately.
950
+ Callback slots can be overridden at call time using underscore-prefixed kwargs:
951
+ _callback_slot, _finished_slot, _except_slot.
952
+
953
+ Args:
954
+ callback_slot: Default callback for return value.
955
+ finished_slot: Default slot called on completion.
956
+ except_slot: Default slot called on exception.
957
+ priority: Thread priority.
958
+ timeout: Auto-cancel timeout in milliseconds.
959
+ block: If True, wait for result before returning.
960
+ key: Thread key for ThreadManager.
961
+ name: Thread name for debugging.
962
+
963
+ Example:
964
+ @threads.method(callback_slot=handle_result)
965
+ def compute(x):
966
+ return x * 2
967
+
968
+ # Use default callback:
969
+ future = compute(5)
970
+
971
+ # Override callback at call time:
972
+ future = compute(5, _callback_slot=other_handler)
973
+ """
974
+
975
+ def decorator(func: Callable[..., T]) -> Callable[..., QThreadFuture]:
976
+ @wraps(func)
977
+ def wrapper(*args: Any, **kwargs: Any) -> QThreadFuture:
978
+ # Extract override kwargs
979
+ cb = kwargs.pop("_callback_slot", callback_slot)
980
+ fs = kwargs.pop("_finished_slot", finished_slot)
981
+ es = kwargs.pop("_except_slot", except_slot)
982
+
983
+ future = QThreadFuture(
984
+ func,
985
+ *args,
986
+ callback_slot=cb,
987
+ finished_slot=fs,
988
+ except_slot=es,
989
+ priority=priority,
990
+ timeout=timeout,
991
+ key=key,
992
+ name=name or func.__name__,
993
+ **kwargs,
994
+ )
995
+ future.start()
996
+
997
+ if block:
998
+ future.result()
999
+
1000
+ return future
1001
+
1002
+ return wrapper
1003
+
1004
+ return decorator
1005
+
1006
+
1007
+ def iterator(
1008
+ yield_slot: Callable[..., Any] | None = None,
1009
+ callback_slot: Callable[..., Any] | None = None,
1010
+ finished_slot: Callable[[], None] | None = None,
1011
+ except_slot: Callable[[Exception], None] | None = None,
1012
+ priority: QThread.Priority = QThread.Priority.InheritPriority,
1013
+ key: str | None = None,
1014
+ name: str | None = None,
1015
+ ) -> Callable[[Callable[..., Generator[Any, None, T]]], Callable[..., QThreadFutureIterator]]:
1016
+ """Decorator to run a generator on a background thread.
1017
+
1018
+ Each yielded value is passed to yield_slot. The final return value
1019
+ goes to callback_slot. Slots can be overridden at call time using
1020
+ underscore-prefixed kwargs.
1021
+
1022
+ Args:
1023
+ yield_slot: Called with each yielded value.
1024
+ callback_slot: Called with the final return value.
1025
+ finished_slot: Called on completion.
1026
+ except_slot: Called on exception.
1027
+ priority: Thread priority.
1028
+ key: Thread key for ThreadManager.
1029
+ name: Thread name for debugging.
1030
+
1031
+ Example:
1032
+ @threads.iterator(yield_slot=update_progress)
1033
+ def process_items(items):
1034
+ for i, item in enumerate(items):
1035
+ yield i / len(items) # Progress
1036
+ process(item)
1037
+ return "done"
1038
+
1039
+ future = process_items(my_items)
1040
+ """
1041
+
1042
+ def decorator(
1043
+ func: Callable[..., Generator[Any, None, T]],
1044
+ ) -> Callable[..., QThreadFutureIterator]:
1045
+ @wraps(func)
1046
+ def wrapper(*args: Any, **kwargs: Any) -> QThreadFutureIterator:
1047
+ # Extract override kwargs
1048
+ ys = kwargs.pop("_yield_slot", yield_slot)
1049
+ cb = kwargs.pop("_callback_slot", callback_slot)
1050
+ fs = kwargs.pop("_finished_slot", finished_slot)
1051
+ es = kwargs.pop("_except_slot", except_slot)
1052
+
1053
+ future = QThreadFutureIterator(
1054
+ func,
1055
+ *args,
1056
+ yield_slot=ys,
1057
+ callback_slot=cb,
1058
+ finished_slot=fs,
1059
+ except_slot=es,
1060
+ priority=priority,
1061
+ key=key,
1062
+ name=name or func.__name__,
1063
+ **kwargs,
1064
+ )
1065
+ future.start()
1066
+
1067
+ return future
1068
+
1069
+ return wrapper
1070
+
1071
+ return decorator