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.
- draftomen/__init__.py +17 -0
- draftomen/assets/draftomen.icns +0 -0
- draftomen/assets/draftomen.ico +0 -0
- draftomen/assets/draftomen_logo.png +0 -0
- draftomen/audit.py +606 -0
- draftomen/backtest.py +449 -0
- draftomen/benchmark.py +834 -0
- draftomen/carddb.py +1869 -0
- draftomen/cardimages.py +313 -0
- draftomen/cli.py +891 -0
- draftomen/config.py +133 -0
- draftomen/deckbuilder.py +2723 -0
- draftomen/events.py +772 -0
- draftomen/logfollow.py +451 -0
- draftomen/mock_session.py +745 -0
- draftomen/paths.py +120 -0
- draftomen/pickengine.py +1117 -0
- draftomen/pool.py +1259 -0
- draftomen/preferences.py +289 -0
- draftomen/qml/AboutDialog.qml +156 -0
- draftomen/qml/AppBar.qml +120 -0
- draftomen/qml/BacktestView.qml +316 -0
- draftomen/qml/BuildView.qml +1151 -0
- draftomen/qml/CardPreview.qml +434 -0
- draftomen/qml/DimensionalButton.qml +42 -0
- draftomen/qml/DimensionalComboBox.qml +182 -0
- draftomen/qml/DimensionalSurface.qml +118 -0
- draftomen/qml/DimensionalTabButton.qml +41 -0
- draftomen/qml/LiveDraftView.qml +468 -0
- draftomen/qml/Main.qml +175 -0
- draftomen/qml/NavigationRail.qml +226 -0
- draftomen/qml/PoolSummaryPanel.qml +322 -0
- draftomen/qml/PrivacyDialog.qml +96 -0
- draftomen/qml/RecentPickThumbnail.qml +83 -0
- draftomen/qml/RecentPicksGallery.qml +333 -0
- draftomen/qml/RecommendationRow.qml +404 -0
- draftomen/qml/SettingsSwitch.qml +141 -0
- draftomen/qml/SettingsView.qml +531 -0
- draftomen/qml/StateBanner.qml +189 -0
- draftomen/qml/StatusStrip.qml +84 -0
- draftomen/qml/Theme.qml +71 -0
- draftomen/qml/qmldir +23 -0
- draftomen/qt_adapter.py +884 -0
- draftomen/qt_gui.py +338 -0
- draftomen/qt_mock.py +63 -0
- draftomen/ranking.py +126 -0
- draftomen/replay.py +480 -0
- draftomen/session.py +3668 -0
- draftomen/setinfo.py +26 -0
- draftomen/seventeen.py +2766 -0
- draftomen/splash.py +617 -0
- draftomen/tui.py +4007 -0
- draftomen/watch.py +336 -0
- draftomen-0.3.0.dist-info/METADATA +156 -0
- draftomen-0.3.0.dist-info/RECORD +59 -0
- draftomen-0.3.0.dist-info/WHEEL +5 -0
- draftomen-0.3.0.dist-info/entry_points.txt +6 -0
- draftomen-0.3.0.dist-info/licenses/LICENSE +21 -0
- draftomen-0.3.0.dist-info/top_level.txt +1 -0
draftomen/cardimages.py
ADDED
|
@@ -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
|
+
|