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.
- lightfall_utils/__init__.py +3 -0
- lightfall_utils/_version.py +24 -0
- lightfall_utils/ca/__init__.py +9 -0
- lightfall_utils/ca/context.py +100 -0
- lightfall_utils/ca/pv.py +307 -0
- lightfall_utils/caproto_shutdown.py +89 -0
- lightfall_utils/config/__init__.py +12 -0
- lightfall_utils/config/layers.py +317 -0
- lightfall_utils/config/manager.py +246 -0
- lightfall_utils/log_buffer.py +231 -0
- lightfall_utils/logging.py +235 -0
- lightfall_utils/py.typed +0 -0
- lightfall_utils/qt_affinity.py +92 -0
- lightfall_utils/theming/__init__.py +30 -0
- lightfall_utils/theming/builtin.py +737 -0
- lightfall_utils/theming/manager.py +1030 -0
- lightfall_utils/theming/provider.py +102 -0
- lightfall_utils/theming/registry.py +195 -0
- lightfall_utils/threads.py +1071 -0
- lightfall_utils-0.1.0.dist-info/METADATA +54 -0
- lightfall_utils-0.1.0.dist-info/RECORD +24 -0
- lightfall_utils-0.1.0.dist-info/WHEEL +4 -0
- lightfall_utils-0.1.0.dist-info/licenses/LEGAL.md +13 -0
- lightfall_utils-0.1.0.dist-info/licenses/LICENSE.md +33 -0
|
@@ -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
|