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,313 @@
1
+ """Resolve, download, and cache card images for any frontend.
2
+ Network and filesystem behavior stay behind a UI-neutral Python service.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import http.client
8
+ import hashlib
9
+ import json
10
+ import tempfile
11
+ import time
12
+ import urllib.error
13
+ import urllib.parse
14
+ import urllib.request
15
+ from collections.abc import Callable, Mapping
16
+ from dataclasses import dataclass, field
17
+ from os import PathLike
18
+ from pathlib import Path
19
+ from threading import RLock
20
+ from typing import Any, TypeAlias
21
+
22
+ from draftomen.carddb import SCRYFALL_USER_AGENT, CardDatabase, CardInfo
23
+ from draftomen.paths import app_data_dir
24
+
25
+ PathInput: TypeAlias = str | PathLike[str]
26
+ ImageUrlOpener: TypeAlias = Callable[..., Any]
27
+ MetadataUrlOpener: TypeAlias = Callable[..., Any]
28
+ MonotonicClock: TypeAlias = Callable[[], float]
29
+ SleepFunction: TypeAlias = Callable[[float], None]
30
+
31
+ CARD_IMAGE_CACHE_DIR_NAME = "card-images"
32
+ CARD_IMAGE_MAX_BYTES = 8 * 1024 * 1024
33
+ CARD_IMAGE_TIMEOUT_SECONDS = 10.0
34
+ CARD_IMAGE_MAX_ATTEMPTS = 2
35
+ CARD_IMAGE_FILE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
36
+ SCRYFALL_NAMED_CARD_URL = "https://api.scryfall.com/cards/named"
37
+ SCRYFALL_NAMED_CARD_MAX_BYTES = 1024 * 1024
38
+ SCRYFALL_NAMED_CARD_MIN_INTERVAL_SECONDS = 0.5
39
+
40
+
41
+ class CardImageError(RuntimeError):
42
+ """Raised when a card image cannot be resolved or cached.
43
+ Frontends decide how and where to present the failure.
44
+ """
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class CardImageService:
49
+ """Provide bounded card-image resolution, download, and caching.
50
+ Failed downloads can be retried without retaining frontend state.
51
+ """
52
+
53
+ cache_dir: Path
54
+ max_bytes: int = CARD_IMAGE_MAX_BYTES
55
+ timeout_seconds: float = CARD_IMAGE_TIMEOUT_SECONDS
56
+ max_attempts: int = CARD_IMAGE_MAX_ATTEMPTS
57
+ opener: ImageUrlOpener = field(
58
+ default=urllib.request.urlopen,
59
+ repr=False,
60
+ compare=False,
61
+ )
62
+ metadata_opener: MetadataUrlOpener = field(
63
+ default=urllib.request.urlopen,
64
+ repr=False,
65
+ compare=False,
66
+ )
67
+ metadata_max_bytes: int = SCRYFALL_NAMED_CARD_MAX_BYTES
68
+ monotonic_clock: MonotonicClock = field(
69
+ default=time.monotonic,
70
+ repr=False,
71
+ compare=False,
72
+ )
73
+ sleep: SleepFunction = field(default=time.sleep, repr=False, compare=False)
74
+ _metadata_uris_by_name: dict[str, str] = field(
75
+ default_factory=dict,
76
+ init=False,
77
+ repr=False,
78
+ compare=False,
79
+ )
80
+ _metadata_rate_state: dict[str, float] = field(
81
+ default_factory=dict,
82
+ init=False,
83
+ repr=False,
84
+ compare=False,
85
+ )
86
+ _metadata_rate_lock: RLock = field(
87
+ default_factory=RLock,
88
+ init=False,
89
+ repr=False,
90
+ compare=False,
91
+ )
92
+
93
+ def __post_init__(self) -> None:
94
+ if self.max_bytes <= 0:
95
+ raise ValueError("Card-image byte limit must be positive.")
96
+ if self.timeout_seconds <= 0:
97
+ raise ValueError("Card-image timeout must be positive.")
98
+ if self.max_attempts <= 0:
99
+ raise ValueError("Card-image attempt limit must be positive.")
100
+ if self.metadata_max_bytes <= 0:
101
+ raise ValueError("Card metadata byte limit must be positive.")
102
+
103
+ def resolve_image_uri(
104
+ self,
105
+ *,
106
+ card: CardInfo,
107
+ card_database: CardDatabase,
108
+ ) -> str | None:
109
+ """Resolve a card's direct or name-indexed Scryfall image URL.
110
+ Unknown cards remain unresolved rather than triggering network work.
111
+ """
112
+
113
+ if card.image_uri is not None:
114
+ return card.image_uri
115
+ if card.unknown:
116
+ return None
117
+
118
+ return card_database.image_uri_for_name(name=card.name)
119
+
120
+ def resolve_focused_image_uri(
121
+ self,
122
+ *,
123
+ card: CardInfo,
124
+ card_database: CardDatabase,
125
+ ) -> str | None:
126
+ """Resolve a selected known card, using one exact Scryfall lookup if needed.
127
+
128
+ Projection callers must use :meth:`resolve_image_uri`, which deliberately
129
+ stays local-only. Successful named lookups are retained in memory.
130
+ """
131
+
132
+ image_uri = self.resolve_image_uri(card=card, card_database=card_database)
133
+ if image_uri is not None or card.unknown:
134
+ return image_uri
135
+
136
+ name_key = _normalized_card_name(name=card.name)
137
+ cached_uri = self._metadata_uris_by_name.get(name_key)
138
+ if cached_uri is not None:
139
+ return cached_uri
140
+
141
+ request = urllib.request.Request(
142
+ f"{SCRYFALL_NAMED_CARD_URL}?exact="
143
+ f"{urllib.parse.quote(card.name, safe='')}",
144
+ headers={
145
+ "Accept": "application/json;q=0.9,*/*;q=0.8",
146
+ "User-Agent": SCRYFALL_USER_AGENT,
147
+ },
148
+ )
149
+ try:
150
+ self._wait_for_metadata_request_slot()
151
+ with self.metadata_opener(
152
+ request,
153
+ timeout=self.timeout_seconds,
154
+ ) as response:
155
+ payload = response.read(self.metadata_max_bytes + 1)
156
+ except urllib.error.HTTPError as error:
157
+ if error.code == 404:
158
+ return None
159
+ raise CardImageError(f"Card metadata lookup failed: {error}") from error
160
+ except (http.client.HTTPException, OSError, urllib.error.URLError) as error:
161
+ raise CardImageError(f"Card metadata lookup failed: {error}") from error
162
+
163
+ if not isinstance(payload, bytes):
164
+ raise CardImageError("Card metadata lookup returned malformed response.")
165
+ if len(payload) > self.metadata_max_bytes:
166
+ raise CardImageError("Card metadata lookup failed: response too large.")
167
+ try:
168
+ card_object = json.loads(payload.decode("utf-8"))
169
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
170
+ raise CardImageError(
171
+ f"Card metadata lookup returned malformed JSON: {error}"
172
+ ) from error
173
+ if not isinstance(card_object, dict):
174
+ raise CardImageError("Card metadata lookup returned malformed JSON object.")
175
+
176
+ image_uri = _scryfall_card_image_uri(card=card_object)
177
+ if image_uri is not None:
178
+ self._metadata_uris_by_name[name_key] = image_uri
179
+ return image_uri
180
+
181
+ def _wait_for_metadata_request_slot(self) -> None:
182
+ """Respect Scryfall's two-requests-per-second named-endpoint limit."""
183
+
184
+ with self._metadata_rate_lock:
185
+ now = self.monotonic_clock()
186
+ last_request_at = self._metadata_rate_state.get("last_request_at")
187
+ if last_request_at is not None:
188
+ next_request_at = (
189
+ last_request_at + SCRYFALL_NAMED_CARD_MIN_INTERVAL_SECONDS
190
+ )
191
+ if now < next_request_at:
192
+ self.sleep(next_request_at - now)
193
+ now = max(self.monotonic_clock(), next_request_at)
194
+ self._metadata_rate_state["last_request_at"] = now
195
+
196
+ def cached_path(self, *, image_uri: str) -> Path:
197
+ """Return the deterministic cache path for one image URL.
198
+ Existing and future downloads share the same collision-resistant key.
199
+ """
200
+
201
+ digest = hashlib.sha256(image_uri.encode("utf-8")).hexdigest()
202
+ extension = _card_image_extension(image_uri=image_uri)
203
+ return self.cache_dir / f"{digest}{extension}"
204
+
205
+ def fetch(self, *, image_uri: str) -> Path:
206
+ """Return a cached image or download it with bounded retries.
207
+ Successful writes are atomic and oversized responses are rejected.
208
+ """
209
+
210
+ image_path = self.cached_path(image_uri=image_uri)
211
+ if image_path.is_file():
212
+ return image_path
213
+
214
+ request = urllib.request.Request(
215
+ image_uri,
216
+ headers={
217
+ "Accept": "image/*,*/*;q=0.8",
218
+ "User-Agent": SCRYFALL_USER_AGENT,
219
+ },
220
+ )
221
+ last_error: OSError | urllib.error.URLError | None = None
222
+ for _ in range(self.max_attempts):
223
+ try:
224
+ with self.opener(
225
+ request,
226
+ timeout=self.timeout_seconds,
227
+ ) as response:
228
+ image_data = response.read(self.max_bytes + 1)
229
+ except (OSError, urllib.error.URLError) as error:
230
+ last_error = error
231
+ continue
232
+
233
+ if len(image_data) > self.max_bytes:
234
+ raise CardImageError("Image fetch failed: response too large.")
235
+
236
+ return self._write_image(image_path=image_path, image_data=image_data)
237
+
238
+ detail = "unknown network error" if last_error is None else str(last_error)
239
+ raise CardImageError(
240
+ f"Image fetch failed after {self.max_attempts} attempts: {detail}"
241
+ ) from last_error
242
+
243
+ def _write_image(self, *, image_path: Path, image_data: bytes) -> Path:
244
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
245
+ temporary_path: Path | None = None
246
+ try:
247
+ with tempfile.NamedTemporaryFile(
248
+ mode="wb",
249
+ dir=self.cache_dir,
250
+ delete=False,
251
+ ) as temporary_file:
252
+ temporary_file.write(image_data)
253
+ temporary_path = Path(temporary_file.name)
254
+
255
+ temporary_path.replace(image_path)
256
+ except OSError as error:
257
+ raise CardImageError(f"Image cache write failed: {error}") from error
258
+ finally:
259
+ if temporary_path is not None and temporary_path.exists():
260
+ temporary_path.unlink()
261
+
262
+ return image_path
263
+
264
+
265
+ def card_image_cache_dir(*, app_dir: PathInput | None = None) -> Path:
266
+ """Return the shared card-image cache directory.
267
+ An explicit application directory keeps tests and portable runs isolated.
268
+ """
269
+
270
+ root = Path(app_data_dir() if app_dir is None else app_dir)
271
+ return root / CARD_IMAGE_CACHE_DIR_NAME
272
+
273
+
274
+ def _card_image_extension(*, image_uri: str) -> str:
275
+ parsed_uri = urllib.parse.urlparse(image_uri)
276
+ extension = Path(parsed_uri.path).suffix.lower()
277
+ if extension in CARD_IMAGE_FILE_EXTENSIONS:
278
+ return extension
279
+
280
+ return ".jpg"
281
+
282
+
283
+ def _normalized_card_name(*, name: str) -> str:
284
+ return " ".join(name.casefold().replace("’", "'").split())
285
+
286
+
287
+ def _scryfall_card_image_uri(*, card: Mapping[str, Any]) -> str | None:
288
+ image_uri = _scryfall_image_uri(value=card.get("image_uris"))
289
+ if image_uri is not None:
290
+ return image_uri
291
+
292
+ faces = card.get("card_faces")
293
+ if not isinstance(faces, list):
294
+ return None
295
+ for face in faces:
296
+ if isinstance(face, Mapping):
297
+ image_uri = _scryfall_image_uri(value=face.get("image_uris"))
298
+ if image_uri is not None:
299
+ return image_uri
300
+
301
+ return None
302
+
303
+
304
+ def _scryfall_image_uri(*, value: Any) -> str | None:
305
+ if not isinstance(value, Mapping):
306
+ return None
307
+ for key in ("normal", "large", "small", "png", "border_crop", "art_crop"):
308
+ image_uri = value.get(key)
309
+ if isinstance(image_uri, str) and image_uri:
310
+ return image_uri
311
+
312
+ return None
313
+