draftomen 0.3.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.
Files changed (59) hide show
  1. draftomen/__init__.py +17 -0
  2. draftomen/assets/draftomen.icns +0 -0
  3. draftomen/assets/draftomen.ico +0 -0
  4. draftomen/assets/draftomen_logo.png +0 -0
  5. draftomen/audit.py +606 -0
  6. draftomen/backtest.py +449 -0
  7. draftomen/benchmark.py +834 -0
  8. draftomen/carddb.py +1869 -0
  9. draftomen/cardimages.py +313 -0
  10. draftomen/cli.py +891 -0
  11. draftomen/config.py +133 -0
  12. draftomen/deckbuilder.py +2723 -0
  13. draftomen/events.py +772 -0
  14. draftomen/logfollow.py +451 -0
  15. draftomen/mock_session.py +745 -0
  16. draftomen/paths.py +120 -0
  17. draftomen/pickengine.py +1117 -0
  18. draftomen/pool.py +1259 -0
  19. draftomen/preferences.py +289 -0
  20. draftomen/qml/AboutDialog.qml +156 -0
  21. draftomen/qml/AppBar.qml +120 -0
  22. draftomen/qml/BacktestView.qml +316 -0
  23. draftomen/qml/BuildView.qml +1151 -0
  24. draftomen/qml/CardPreview.qml +434 -0
  25. draftomen/qml/DimensionalButton.qml +42 -0
  26. draftomen/qml/DimensionalComboBox.qml +182 -0
  27. draftomen/qml/DimensionalSurface.qml +118 -0
  28. draftomen/qml/DimensionalTabButton.qml +41 -0
  29. draftomen/qml/LiveDraftView.qml +468 -0
  30. draftomen/qml/Main.qml +175 -0
  31. draftomen/qml/NavigationRail.qml +226 -0
  32. draftomen/qml/PoolSummaryPanel.qml +322 -0
  33. draftomen/qml/PrivacyDialog.qml +96 -0
  34. draftomen/qml/RecentPickThumbnail.qml +83 -0
  35. draftomen/qml/RecentPicksGallery.qml +333 -0
  36. draftomen/qml/RecommendationRow.qml +404 -0
  37. draftomen/qml/SettingsSwitch.qml +141 -0
  38. draftomen/qml/SettingsView.qml +531 -0
  39. draftomen/qml/StateBanner.qml +189 -0
  40. draftomen/qml/StatusStrip.qml +84 -0
  41. draftomen/qml/Theme.qml +71 -0
  42. draftomen/qml/qmldir +23 -0
  43. draftomen/qt_adapter.py +884 -0
  44. draftomen/qt_gui.py +338 -0
  45. draftomen/qt_mock.py +63 -0
  46. draftomen/ranking.py +126 -0
  47. draftomen/replay.py +480 -0
  48. draftomen/session.py +3668 -0
  49. draftomen/setinfo.py +26 -0
  50. draftomen/seventeen.py +2766 -0
  51. draftomen/splash.py +617 -0
  52. draftomen/tui.py +4007 -0
  53. draftomen/watch.py +336 -0
  54. draftomen-0.3.0.dist-info/METADATA +156 -0
  55. draftomen-0.3.0.dist-info/RECORD +59 -0
  56. draftomen-0.3.0.dist-info/WHEEL +5 -0
  57. draftomen-0.3.0.dist-info/entry_points.txt +6 -0
  58. draftomen-0.3.0.dist-info/licenses/LICENSE +21 -0
  59. draftomen-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,884 @@
1
+ """Translate shared live-session state and commands for Qt frontends.
2
+ Keep blocking session work in adapter-owned workers and QML values presentation-only.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import threading
8
+ from collections.abc import Callable
9
+ from dataclasses import fields, is_dataclass, replace
10
+ from enum import Enum
11
+ from os import PathLike
12
+ from typing import Any, Literal, TypeAlias, cast
13
+
14
+ from PySide6.QtCore import (
15
+ QAbstractListModel,
16
+ QByteArray,
17
+ QCoreApplication,
18
+ QEvent,
19
+ QModelIndex,
20
+ QMetaObject,
21
+ QObject,
22
+ Property,
23
+ QThread,
24
+ QTimer,
25
+ QUrl,
26
+ Qt,
27
+ Signal,
28
+ Slot,
29
+ )
30
+ from PySide6.QtGui import QFontInfo, QGuiApplication
31
+
32
+ from draftomen.preferences import (
33
+ GuiDisplayPreferences,
34
+ load_gui_preferences,
35
+ save_gui_preferences,
36
+ )
37
+ from draftomen.ranking import RankingMode
38
+ from draftomen.session import (
39
+ CardImageFetchResult,
40
+ CardImageRequest,
41
+ ChangeRanking,
42
+ ChangeSplashPreference,
43
+ ChooseAccount,
44
+ ChooseRecommendation,
45
+ DismissError,
46
+ FocusBuildCard,
47
+ LiveSession,
48
+ LiveSessionCommand,
49
+ LiveSessionSnapshot,
50
+ RequestBacktest,
51
+ RequestBuild,
52
+ RequestRatingsDownload,
53
+ RetryError,
54
+ SnapshotPublisher,
55
+ )
56
+
57
+ SessionFactory = Callable[[SnapshotPublisher], LiveSession]
58
+ _ImageRequestKind: TypeAlias = Literal["selected", "recommendation", "recent"]
59
+ _OMITTED_SNAPSHOT_FIELDS = frozenset(("current_pack_event", "current_scored_pack"))
60
+
61
+ _DEFAULT_APPLICATION_FONT_PIXEL_SIZE = 13
62
+
63
+
64
+ _WORKER_ERROR_ID = "qt-worker-error"
65
+
66
+ _GUI_PREFERENCES_SAVE_LOCK = threading.Lock()
67
+
68
+
69
+ def _to_qml_value(value: Any) -> Any:
70
+ """Convert immutable application values into plain QML-safe values.
71
+ Domain-only and event payloads never cross the frontend boundary.
72
+ """
73
+
74
+ if isinstance(value, Enum):
75
+ return value.value
76
+ if is_dataclass(value) and not isinstance(value, type):
77
+ converted = {
78
+ field.name: _to_qml_value(getattr(value, field.name))
79
+ for field in fields(value)
80
+ if not field.name.startswith("domain_")
81
+ and field.name not in _OMITTED_SNAPSHOT_FIELDS
82
+ }
83
+ image_path = converted.get("image_path")
84
+ if image_path is not None:
85
+ converted["image_path"] = QUrl.fromLocalFile(image_path).toString()
86
+ return converted
87
+ if isinstance(value, tuple):
88
+ return [_to_qml_value(item) for item in value]
89
+ if isinstance(value, dict):
90
+ return {
91
+ str(key): _to_qml_value(item)
92
+ for key, item in value.items()
93
+ }
94
+ return value
95
+
96
+
97
+ class RecommendationListModel(QAbstractListModel):
98
+ """Publish recommendation rows through a narrow Qt item model.
99
+ Each row is the same plain mapping available in the immutable state projection.
100
+ """
101
+
102
+ MODEL_DATA_ROLE = Qt.ItemDataRole.UserRole + 1
103
+
104
+ def __init__(self, *, parent: QObject | None = None) -> None:
105
+ super().__init__(parent)
106
+ self._rows: list[dict[str, Any]] = []
107
+
108
+ def roleNames(self) -> dict[int, QByteArray]:
109
+ return {self.MODEL_DATA_ROLE: QByteArray(b"modelData")}
110
+
111
+ def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
112
+ if parent.isValid():
113
+ return 0
114
+ return len(self._rows)
115
+
116
+ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
117
+ if not index.isValid() or not 0 <= index.row() < len(self._rows):
118
+ return None
119
+ if role == self.MODEL_DATA_ROLE:
120
+ return self._rows[index.row()]
121
+ return None
122
+
123
+ def replace(self, *, rows: list[dict[str, Any]]) -> None:
124
+ if rows == self._rows:
125
+ return
126
+
127
+ previous_count = len(self._rows)
128
+ next_count = len(rows)
129
+ shared_count = min(previous_count, next_count)
130
+ changed_rows = [
131
+ row
132
+ for row in range(shared_count)
133
+ if self._rows[row] != rows[row]
134
+ ]
135
+
136
+ if changed_rows:
137
+ self._rows[:shared_count] = rows[:shared_count]
138
+ first_changed = changed_rows[0]
139
+ last_changed = changed_rows[-1]
140
+ self.dataChanged.emit(
141
+ self.index(first_changed, 0),
142
+ self.index(last_changed, 0),
143
+ [self.MODEL_DATA_ROLE],
144
+ )
145
+
146
+ if next_count < previous_count:
147
+ self.beginRemoveRows(
148
+ QModelIndex(),
149
+ next_count,
150
+ previous_count - 1,
151
+ )
152
+ del self._rows[next_count:]
153
+ self.endRemoveRows()
154
+ return
155
+
156
+ if next_count > previous_count:
157
+ self.beginInsertRows(
158
+ QModelIndex(),
159
+ previous_count,
160
+ next_count - 1,
161
+ )
162
+ self._rows.extend(rows[previous_count:])
163
+ self.endInsertRows()
164
+
165
+
166
+ class _GuiPreferencesSaveThread(QThread):
167
+ """Serialize immutable GUI preference snapshots on one worker thread.
168
+ At most one snapshot is active; pending work is coalesced to the newest.
169
+ """
170
+
171
+ saveFinished = Signal(int, str)
172
+
173
+ def __init__(
174
+ self,
175
+ *,
176
+ app_dir: str | PathLike[str] | None,
177
+ parent: QObject | None = None,
178
+ ) -> None:
179
+ super().__init__(parent)
180
+ self._app_dir = app_dir
181
+ self._condition = threading.Condition()
182
+ self._pending: tuple[int, GuiDisplayPreferences] | None = None
183
+ self._stop_requested = False
184
+
185
+ def enqueue(
186
+ self,
187
+ *,
188
+ generation: int,
189
+ preferences: GuiDisplayPreferences,
190
+ ) -> None:
191
+ with self._condition:
192
+ if self._stop_requested:
193
+ return
194
+ self._pending = (generation, preferences)
195
+ self._condition.notify()
196
+
197
+ def request_stop(self) -> None:
198
+ with self._condition:
199
+ self._stop_requested = True
200
+ self._condition.notify()
201
+
202
+ def run(self) -> None:
203
+ while True:
204
+ with self._condition:
205
+ while self._pending is None and not self._stop_requested:
206
+ self._condition.wait()
207
+ if self._pending is None:
208
+ return
209
+ generation, preferences = self._pending
210
+ self._pending = None
211
+
212
+ try:
213
+ with _GUI_PREFERENCES_SAVE_LOCK:
214
+ persistence_message = save_gui_preferences(
215
+ preferences=preferences,
216
+ app_dir=self._app_dir,
217
+ )
218
+ except Exception as error: # pragma: no cover - defensive boundary.
219
+ persistence_message = f"Could not save GUI preferences: {error}"
220
+ self.saveFinished.emit(generation, persistence_message or "Saved")
221
+
222
+
223
+ class GuiPreferencesAdapter(QObject):
224
+ """Expose persisted display-only GUI choices through narrow Qt properties.
225
+ Ranking and splash choices remain explicit commands on SessionAdapter.
226
+ """
227
+
228
+ preferencesChanged = Signal()
229
+ persistenceChanged = Signal()
230
+ applicationFontPixelSizeChanged = Signal()
231
+
232
+ def __init__(
233
+ self,
234
+ *,
235
+ app_dir: str | PathLike[str] | None = None,
236
+ parent: QObject | None = None,
237
+ ) -> None:
238
+ super().__init__(parent)
239
+ self._app_dir = app_dir
240
+ self._preferences, self._persistence_message = load_gui_preferences(
241
+ app_dir=app_dir,
242
+ )
243
+ self._save_generation = 0
244
+ self._save_thread: _GuiPreferencesSaveThread | None = None
245
+ self._closing = False
246
+
247
+ application = QCoreApplication.instance()
248
+ if application is not None:
249
+ application.aboutToQuit.connect(self.shutdown)
250
+ if isinstance(application, QGuiApplication):
251
+ application.installEventFilter(self)
252
+
253
+ def __del__(self) -> None:
254
+ try:
255
+ self.shutdown()
256
+ except Exception:
257
+ pass
258
+
259
+ def eventFilter(self, _watched: QObject, event: QEvent) -> bool:
260
+ if event.type() == QEvent.Type.ApplicationFontChange:
261
+ self.applicationFontPixelSizeChanged.emit()
262
+ return super().eventFilter(_watched, event)
263
+
264
+ @Property(bool, notify=preferencesChanged)
265
+ def compactDensity(self) -> bool:
266
+ return self._preferences.compact_density
267
+
268
+ @Property(bool, notify=preferencesChanged)
269
+ def secondaryStats(self) -> bool:
270
+ return self._preferences.secondary_stats
271
+
272
+ @Property(bool, notify=preferencesChanged)
273
+ def cardPreview(self) -> bool:
274
+ return self._preferences.card_preview
275
+
276
+ @Property(bool, notify=preferencesChanged)
277
+ def detailedBuildContext(self) -> bool:
278
+ return self._preferences.detailed_build_context
279
+
280
+ @Property(bool, notify=preferencesChanged)
281
+ def systemTextScaling(self) -> bool:
282
+ return self._preferences.system_text_scaling
283
+
284
+ @Property(bool, notify=preferencesChanged)
285
+ def showBacktest(self) -> bool:
286
+ return self._preferences.show_backtest
287
+
288
+ @Property(int, notify=applicationFontPixelSizeChanged)
289
+ def applicationFontPixelSize(self) -> int:
290
+ application = QGuiApplication.instance()
291
+ if not isinstance(application, QGuiApplication):
292
+ return _DEFAULT_APPLICATION_FONT_PIXEL_SIZE
293
+ pixel_size = QFontInfo(application.font()).pixelSize()
294
+ return (
295
+ pixel_size
296
+ if pixel_size > 0
297
+ else _DEFAULT_APPLICATION_FONT_PIXEL_SIZE
298
+ )
299
+
300
+ @Property(str, notify=persistenceChanged)
301
+ def persistenceMessage(self) -> str:
302
+ return self._persistence_message or "Saved"
303
+
304
+ @Slot(bool)
305
+ def setCompactDensity(self, enabled: bool) -> None:
306
+ self._replace_preferences(compact_density=enabled)
307
+
308
+ @Slot(bool)
309
+ def setSecondaryStats(self, enabled: bool) -> None:
310
+ self._replace_preferences(secondary_stats=enabled)
311
+
312
+ @Slot(bool)
313
+ def setCardPreview(self, enabled: bool) -> None:
314
+ self._replace_preferences(card_preview=enabled)
315
+
316
+ @Slot(bool)
317
+ def setDetailedBuildContext(self, enabled: bool) -> None:
318
+ self._replace_preferences(detailed_build_context=enabled)
319
+
320
+ @Slot(bool)
321
+ def setSystemTextScaling(self, enabled: bool) -> None:
322
+ self._replace_preferences(system_text_scaling=enabled)
323
+
324
+ @Slot(bool)
325
+ def setShowBacktest(self, enabled: bool) -> None:
326
+ self._replace_preferences(show_backtest=enabled)
327
+
328
+ @Slot()
329
+ def shutdown(self) -> None:
330
+ self._closing = True
331
+ thread = self._save_thread
332
+ if thread is None:
333
+ return
334
+ thread.request_stop()
335
+ thread.wait()
336
+
337
+ def _ensure_save_thread(self) -> _GuiPreferencesSaveThread:
338
+ thread = self._save_thread
339
+ if thread is None:
340
+ thread = _GuiPreferencesSaveThread(
341
+ app_dir=self._app_dir,
342
+ parent=self,
343
+ )
344
+ thread.saveFinished.connect(
345
+ self._apply_save_result,
346
+ Qt.ConnectionType.QueuedConnection,
347
+ )
348
+ self._save_thread = thread
349
+ if not thread.isRunning():
350
+ thread.start()
351
+ return thread
352
+
353
+ @Slot(int, str)
354
+ def _apply_save_result(
355
+ self,
356
+ generation: int,
357
+ persistence_message: str,
358
+ ) -> None:
359
+ if self._closing or generation != self._save_generation:
360
+ return
361
+ self._persistence_message = persistence_message
362
+ self.persistenceChanged.emit()
363
+
364
+ def _replace_preferences(self, **changes: bool) -> None:
365
+ if self._closing:
366
+ return
367
+ updated = replace(self._preferences, **changes)
368
+ if updated == self._preferences:
369
+ return
370
+ self._preferences = updated
371
+ self._save_generation += 1
372
+ generation = self._save_generation
373
+ self._persistence_message = "Saving…"
374
+ self.preferencesChanged.emit()
375
+ self.persistenceChanged.emit()
376
+ self._ensure_save_thread().enqueue(
377
+ generation=generation,
378
+ preferences=updated,
379
+ )
380
+
381
+
382
+ class SessionAdapter(QObject):
383
+ """Expose one QML-facing provider contract for mock and live sessions.
384
+ Subclasses choose synchronous mock dispatch or queued live dispatch.
385
+ """
386
+
387
+ stateChanged = Signal()
388
+ scenarioChanged = Signal()
389
+
390
+ def __init__(
391
+ self,
392
+ *,
393
+ snapshot: LiveSessionSnapshot | None = None,
394
+ parent: QObject | None = None,
395
+ ) -> None:
396
+ super().__init__(parent)
397
+ self._state: dict[str, Any] = {}
398
+ self._state_before_failure: dict[str, Any] | None = None
399
+ self._recommendations_model = RecommendationListModel(parent=self)
400
+ self._publish(snapshot=LiveSessionSnapshot() if snapshot is None else snapshot)
401
+
402
+ @Property("QVariantMap", notify=stateChanged)
403
+ def state(self) -> dict[str, Any]:
404
+ return self._state
405
+
406
+ @Property(QObject, constant=True)
407
+ def recommendationsModel(self) -> RecommendationListModel:
408
+ return self._recommendations_model
409
+
410
+ @Property(bool, constant=True)
411
+ def mockMode(self) -> bool:
412
+ return False
413
+
414
+ @Property(str, notify=scenarioChanged)
415
+ def scenario(self) -> str:
416
+ return ""
417
+
418
+ @Property("QStringList", notify=scenarioChanged)
419
+ def scenarios(self) -> list[str]:
420
+ return []
421
+
422
+ @Slot(str)
423
+ def selectScenario(self, scenario: str) -> None:
424
+ del scenario
425
+
426
+ @Slot(str)
427
+ def chooseAccount(self, account_id: str) -> None:
428
+ self._dispatch(command=ChooseAccount(account_id=account_id))
429
+
430
+ @Slot(int)
431
+ def chooseRecommendation(self, grp_id: int) -> None:
432
+ self._dispatch(command=ChooseRecommendation(grp_id=grp_id))
433
+
434
+ @Slot(int)
435
+ def focusBuildCard(self, grp_id: int) -> None:
436
+ self._dispatch(command=FocusBuildCard(grp_id=grp_id))
437
+
438
+ @Slot(str)
439
+ def changeRanking(self, ranking_mode: str) -> None:
440
+ self._dispatch(
441
+ command=ChangeRanking(
442
+ ranking_mode=cast(RankingMode, ranking_mode),
443
+ )
444
+ )
445
+
446
+ @Slot(bool)
447
+ def setSplashEnabled(self, enabled: bool) -> None:
448
+ self._dispatch(command=ChangeSplashPreference(enabled=enabled))
449
+
450
+ @Slot()
451
+ def requestRatings(self) -> None:
452
+ ratings = self._state.get("ratings", {})
453
+ set_code = ratings.get("set_code")
454
+ if not set_code:
455
+ return
456
+ self._dispatch(command=RequestRatingsDownload(set_code=set_code))
457
+
458
+ @Slot(str)
459
+ def requestBuild(self, pair_override: str) -> None:
460
+ recommendations = self._state.get("recommendations", {})
461
+ self._dispatch(
462
+ command=RequestBuild(
463
+ pair_override=pair_override or None,
464
+ allow_splash=bool(recommendations.get("splash_enabled", True)),
465
+ )
466
+ )
467
+
468
+ @Slot()
469
+ def requestBacktest(self) -> None:
470
+ self._dispatch(command=RequestBacktest())
471
+
472
+ @Slot(str)
473
+ def dismissError(self, error_id: str) -> None:
474
+ if error_id == _WORKER_ERROR_ID and self._state_before_failure is not None:
475
+ state = self._state_before_failure
476
+ self._state_before_failure = None
477
+ self._replace_state(state=state)
478
+ return
479
+ self._dispatch(command=DismissError(error_id=error_id))
480
+
481
+ @Slot(str)
482
+ def retryError(self, error_id: str) -> None:
483
+ self._dispatch(command=RetryError(error_id=error_id))
484
+
485
+ def _dispatch(self, *, command: LiveSessionCommand) -> None:
486
+ raise NotImplementedError
487
+
488
+ @Slot(object)
489
+ def _apply_snapshot(self, snapshot: LiveSessionSnapshot) -> None:
490
+ self._state_before_failure = None
491
+ self._publish(snapshot=snapshot)
492
+
493
+ @Slot(str)
494
+ def _apply_failure(self, message: str) -> None:
495
+ if self._state_before_failure is None:
496
+ self._state_before_failure = self._state
497
+ state = dict(self._state)
498
+ state["status"] = {
499
+ "phase": "error",
500
+ "message": "Draft Omen could not complete background work.",
501
+ }
502
+ state["progress"] = None
503
+ state["errors"] = [
504
+ {
505
+ "error_id": _WORKER_ERROR_ID,
506
+ "code": "qt_worker_error",
507
+ "message": message,
508
+ "recoverable": False,
509
+ "operation": None,
510
+ }
511
+ ]
512
+ self._replace_state(state=state)
513
+
514
+ def _publish(self, *, snapshot: LiveSessionSnapshot) -> None:
515
+ self._replace_state(state=cast(dict[str, Any], _to_qml_value(snapshot)))
516
+
517
+ def _replace_state(self, *, state: dict[str, Any]) -> None:
518
+ if state == self._state:
519
+ return
520
+ self._state = state
521
+ recommendations = state.get("recommendations") or {}
522
+ rows = recommendations.get("cards") or []
523
+ self._recommendations_model.replace(rows=list(rows))
524
+ self.stateChanged.emit()
525
+
526
+
527
+ class _CardImageFetchWorker(QObject):
528
+ """Execute one session-owned card image fetch outside the session thread."""
529
+
530
+ resultReady = Signal(object, object, str)
531
+
532
+ def __init__(self, *, session: LiveSession) -> None:
533
+ super().__init__()
534
+ self._session = session
535
+
536
+ @Slot(object)
537
+ def fetch(self, request: CardImageRequest) -> None:
538
+ try:
539
+ result = self._session.fetch_card_image(request=request)
540
+ except Exception as error: # pragma: no cover - network boundary.
541
+ self.resultReady.emit(request, None, str(error))
542
+ else:
543
+ self.resultReady.emit(request, result, "")
544
+
545
+
546
+ class _LiveSessionWorker(QObject):
547
+ _imageFetchRequested = Signal(object)
548
+ _imageScheduleRequested = Signal()
549
+ snapshotReady = Signal(object)
550
+ failed = Signal(str)
551
+ finished = Signal()
552
+
553
+ def __init__(
554
+ self,
555
+ *,
556
+ session_factory: SessionFactory,
557
+ poll_interval_ms: int,
558
+ startup_scan: bool,
559
+ ) -> None:
560
+ super().__init__()
561
+ self._session_factory = session_factory
562
+ self._poll_interval_ms = poll_interval_ms
563
+ self._startup_scan = startup_scan
564
+ self._session: LiveSession | None = None
565
+ self._timer: QTimer | None = None
566
+ self._stop_requested = False
567
+ self._stopped = False
568
+ self._startup_loading = False
569
+ self._startup_snapshot: LiveSessionSnapshot | None = None
570
+ self._image_thread: QThread | None = None
571
+ self._image_worker: _CardImageFetchWorker | None = None
572
+ self._image_request_in_flight: CardImageRequest | None = None
573
+ self._image_request_kind: _ImageRequestKind | None = None
574
+
575
+ def _publish_snapshot(self, snapshot: LiveSessionSnapshot) -> None:
576
+ if self._stop_requested:
577
+ return
578
+ if self._startup_loading:
579
+ self._startup_snapshot = snapshot
580
+ return
581
+ self.snapshotReady.emit(snapshot)
582
+
583
+ def _flush_startup_snapshot(self) -> None:
584
+ snapshot = self._startup_snapshot
585
+ self._startup_snapshot = None
586
+ self._startup_loading = False
587
+ if snapshot is not None and not self._stop_requested:
588
+ self.snapshotReady.emit(snapshot)
589
+
590
+ def _start_image_worker(self) -> None:
591
+ """Start the dedicated worker used for blocking image retrieval."""
592
+
593
+ session = self._session
594
+ if session is None or self._image_thread is not None:
595
+ return
596
+ thread = QThread(parent=self)
597
+ image_worker = _CardImageFetchWorker(session=session)
598
+ image_worker.moveToThread(thread)
599
+ self._imageFetchRequested.connect(
600
+ image_worker.fetch,
601
+ Qt.ConnectionType.QueuedConnection,
602
+ )
603
+ # Queue completion so all LiveSession mutation stays on this worker.
604
+ image_worker.resultReady.connect(
605
+ self._image_fetch_finished,
606
+ Qt.ConnectionType.QueuedConnection,
607
+ )
608
+ self._imageScheduleRequested.connect(
609
+ self._request_one_card_image,
610
+ Qt.ConnectionType.QueuedConnection,
611
+ )
612
+ thread.finished.connect(image_worker.deleteLater)
613
+ self._image_thread = thread
614
+ self._image_worker = image_worker
615
+ thread.start()
616
+
617
+
618
+ def request_stop(self) -> None:
619
+ """Request cooperative stop without queuing behind busy session work."""
620
+ self._stop_requested = True
621
+
622
+ @Slot()
623
+ def start(self) -> None:
624
+ self._startup_loading = True
625
+ self._startup_snapshot = None
626
+ try:
627
+ self._session = self._session_factory(self._publish_snapshot)
628
+ self._start_image_worker()
629
+ self._publish_snapshot(self._session.snapshot)
630
+ if self._stop_requested:
631
+ self.stop()
632
+ return
633
+ if hasattr(self._session, "load_card_data"):
634
+ self._session.load_card_data()
635
+ if self._stop_requested:
636
+ self.stop()
637
+ return
638
+ if self._startup_scan:
639
+ self._session.scan_startup_files(
640
+ include_previous=True,
641
+ include_pre_draft_detection=False,
642
+ )
643
+ if self._stop_requested:
644
+ self.stop()
645
+ return
646
+ initial_poll_succeeded = self._poll()
647
+ if self._stop_requested:
648
+ self.stop()
649
+ return
650
+ if initial_poll_succeeded:
651
+ self._publish_snapshot(self._session.snapshot)
652
+ self._flush_startup_snapshot()
653
+ else:
654
+ self._startup_snapshot = None
655
+ self._startup_loading = False
656
+ self._timer = QTimer(self)
657
+ self._timer.setInterval(self._poll_interval_ms)
658
+ self._timer.timeout.connect(self._poll)
659
+ self._timer.start()
660
+ except Exception as error: # pragma: no cover - defensive UI boundary.
661
+ if not self._stop_requested:
662
+ self.failed.emit(str(error))
663
+ self.stop()
664
+
665
+ @Slot(object)
666
+ def dispatch(self, command: LiveSessionCommand) -> None:
667
+ if self._session is None or self._stop_requested:
668
+ return
669
+ try:
670
+ self._session.dispatch(command=command)
671
+ self._request_one_card_image()
672
+ except Exception as error: # pragma: no cover - defensive UI boundary.
673
+ self.failed.emit(str(error))
674
+
675
+ @Slot()
676
+ def _poll(self) -> bool:
677
+ if self._session is None:
678
+ return False
679
+ if self._stop_requested:
680
+ self.stop()
681
+ return False
682
+ try:
683
+ snapshot = self._session.poll_once()
684
+ self._publish_snapshot(snapshot)
685
+ self._request_one_card_image()
686
+ except Exception as error: # pragma: no cover - defensive UI boundary.
687
+ self.failed.emit(str(error))
688
+ return False
689
+ finally:
690
+ if self._stop_requested:
691
+ self.stop()
692
+ return True
693
+
694
+ def _request_one_card_image(self) -> None:
695
+ """Schedule one image, prioritizing focus before pack thumbnails."""
696
+
697
+ session = self._session
698
+ if (
699
+ session is None
700
+ or self._stop_requested
701
+ or self._image_request_in_flight is not None
702
+ or self._image_thread is None
703
+ ):
704
+ return
705
+
706
+ request: CardImageRequest | None = None
707
+ request_kind: _ImageRequestKind | None = None
708
+ for kind, method_name in (
709
+ ("selected", "selected_card_image_request"),
710
+ ("recommendation", "recommendation_image_request"),
711
+ ("recent", "recent_pick_image_request"),
712
+ ):
713
+ get_request = getattr(session, method_name, None)
714
+ if not callable(get_request):
715
+ continue
716
+ request = get_request()
717
+ if request is not None:
718
+ request_kind = cast(_ImageRequestKind, kind)
719
+ break
720
+ if request is None or request_kind is None or self._stop_requested:
721
+ return
722
+
723
+ self._image_request_in_flight = request
724
+ self._image_request_kind = request_kind
725
+ self._imageFetchRequested.emit(request)
726
+
727
+ @Slot(object, object, str)
728
+ def _image_fetch_finished(
729
+ self,
730
+ request: CardImageRequest,
731
+ result: object,
732
+ error_message: str,
733
+ ) -> None:
734
+ """Apply the result in the session thread and queue the next request."""
735
+ if request != self._image_request_in_flight:
736
+ return
737
+ request_kind = self._image_request_kind
738
+ self._image_request_in_flight = None
739
+ self._image_request_kind = None
740
+ session = self._session
741
+ if session is None or self._stop_requested:
742
+ return
743
+
744
+ try:
745
+ if error_message:
746
+ if request_kind == "recommendation":
747
+ session.fail_recommendation_image_request(
748
+ request=request,
749
+ error_message=error_message,
750
+ )
751
+ elif request_kind == "recent":
752
+ session.fail_recent_pick_image_request(
753
+ request=request,
754
+ error_message=error_message,
755
+ )
756
+ else:
757
+ session.fail_card_image_request(
758
+ request=request,
759
+ error_message=error_message,
760
+ )
761
+ else:
762
+ if not isinstance(result, CardImageFetchResult):
763
+ raise TypeError("Card image worker returned an invalid result.")
764
+ if request_kind == "recommendation":
765
+ session.complete_recommendation_image_request(
766
+ request=request,
767
+ image_path=result.image_path,
768
+ image_uri=result.image_uri,
769
+ )
770
+ elif request_kind == "recent":
771
+ session.complete_recent_pick_image_request(
772
+ request=request,
773
+ image_path=result.image_path,
774
+ image_uri=result.image_uri,
775
+ )
776
+ else:
777
+ session.complete_card_image_request(
778
+ request=request,
779
+ image_path=result.image_path,
780
+ image_uri=result.image_uri,
781
+ )
782
+ except Exception as error: # pragma: no cover - defensive UI boundary.
783
+ self.failed.emit(str(error))
784
+ finally:
785
+ self._imageScheduleRequested.emit()
786
+
787
+ @Slot()
788
+ def stop(self) -> None:
789
+ self._stop_requested = True
790
+ self._startup_loading = False
791
+ self._startup_snapshot = None
792
+ if self._stopped:
793
+ return
794
+ if self._timer is not None:
795
+ self._timer.stop()
796
+ if self._session is not None:
797
+ try:
798
+ self._session.stop()
799
+ except Exception as error: # pragma: no cover - defensive UI boundary.
800
+ self.failed.emit(str(error))
801
+ image_thread = self._image_thread
802
+ if image_thread is not None:
803
+ image_thread.quit()
804
+ image_thread.wait()
805
+ self._image_thread = None
806
+ self._image_worker = None
807
+ self._stopped = True
808
+ self.finished.emit()
809
+
810
+
811
+ class LiveSessionAdapter(SessionAdapter):
812
+ """Run the production live session on one adapter-owned QThread.
813
+ Immutable snapshots return through queued Qt signals.
814
+ """
815
+
816
+ _commandRequested = Signal(object)
817
+
818
+ def __init__(
819
+ self,
820
+ *,
821
+ session_factory: SessionFactory,
822
+ poll_interval_ms: int,
823
+ startup_scan: bool = True,
824
+ parent: QObject | None = None,
825
+ ) -> None:
826
+ if poll_interval_ms <= 0:
827
+ raise ValueError("poll_interval_ms must be greater than zero.")
828
+ super().__init__(parent=parent)
829
+ self._session_factory = session_factory
830
+ self._poll_interval_ms = poll_interval_ms
831
+ self._startup_scan = startup_scan
832
+ self.thread: QThread | None = None
833
+ self._worker: _LiveSessionWorker | None = None
834
+
835
+ @Slot()
836
+ def start(self) -> None:
837
+ if self.thread is not None and self.thread.isRunning():
838
+ return
839
+ thread = QThread(parent=self)
840
+ worker = _LiveSessionWorker(
841
+ session_factory=self._session_factory,
842
+ poll_interval_ms=self._poll_interval_ms,
843
+ startup_scan=self._startup_scan,
844
+ )
845
+ worker.moveToThread(thread)
846
+ thread.started.connect(worker.start)
847
+ self._commandRequested.connect(worker.dispatch, Qt.ConnectionType.QueuedConnection)
848
+ worker.snapshotReady.connect(
849
+ self._apply_snapshot,
850
+ Qt.ConnectionType.QueuedConnection,
851
+ )
852
+ worker.failed.connect(self._apply_failure, Qt.ConnectionType.QueuedConnection)
853
+ thread.finished.connect(worker.deleteLater)
854
+ worker.finished.connect(
855
+ thread.quit,
856
+ Qt.ConnectionType.DirectConnection,
857
+ )
858
+ self.thread = thread
859
+ self._worker = worker
860
+ thread.start()
861
+
862
+ @Slot()
863
+ def shutdown(self) -> None:
864
+ thread = self.thread
865
+ worker = self._worker
866
+ if thread is None or worker is None or not thread.isRunning():
867
+ return
868
+ worker.request_stop()
869
+ thread.requestInterruption()
870
+ QMetaObject.invokeMethod(
871
+ worker,
872
+ "stop",
873
+ Qt.ConnectionType.QueuedConnection,
874
+ )
875
+ thread.wait(100)
876
+
877
+ def wait_for_shutdown(self) -> None:
878
+ """Wait for the owned worker thread before the adapter is destroyed."""
879
+ thread = self.thread
880
+ if thread is not None and thread.isRunning():
881
+ thread.wait()
882
+
883
+ def _dispatch(self, *, command: LiveSessionCommand) -> None:
884
+ self._commandRequested.emit(command)