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/carddb.py
ADDED
|
@@ -0,0 +1,1869 @@
|
|
|
1
|
+
"""Local card metadata cache backed by Scryfall and Arena data.
|
|
2
|
+
Translate Arena grpIds into display-ready card facts for replay and scoring.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import gzip
|
|
8
|
+
import io
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import tempfile
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.parse
|
|
15
|
+
import urllib.request
|
|
16
|
+
from collections.abc import Iterable, Iterator, Mapping
|
|
17
|
+
from dataclasses import dataclass, field, replace
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from os import PathLike
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, TypeAlias
|
|
22
|
+
|
|
23
|
+
from draftomen import __version__
|
|
24
|
+
from draftomen.paths import app_data_dir
|
|
25
|
+
|
|
26
|
+
PathInput: TypeAlias = str | PathLike[str]
|
|
27
|
+
|
|
28
|
+
CARD_DATABASE_CACHE_FILENAME = "carddb.json"
|
|
29
|
+
SCRYFALL_BULK_DATA_URL = "https://api.scryfall.com/bulk-data"
|
|
30
|
+
SCRYFALL_DEFAULT_CARDS_TYPE = "default_cards"
|
|
31
|
+
MTGJSON_SET_URL_TEMPLATE = "https://mtgjson.com/api/v5/{set_code}.json"
|
|
32
|
+
SCRYFALL_USER_AGENT = (
|
|
33
|
+
f"draftomen/{__version__} "
|
|
34
|
+
"(+https://github.com/andreagrandi/draftomen)"
|
|
35
|
+
)
|
|
36
|
+
ARENA_DATA_CARDS_PREFIX = "data_cards"
|
|
37
|
+
ARENA_DATA_LOC_PREFIX = "data_loc"
|
|
38
|
+
ARENA_DATA_FILE_SUFFIXES = (".mtga", ".json", ".js")
|
|
39
|
+
HTTP_TIMEOUT_SECONDS = 60
|
|
40
|
+
COLOR_ORDER = ("W", "U", "B", "R", "G")
|
|
41
|
+
ARENA_COLOR_ID_MAP = {1: "W", 2: "U", 3: "B", 4: "R", 5: "G"}
|
|
42
|
+
ARENA_RARITY_ID_MAP = {
|
|
43
|
+
0: "token",
|
|
44
|
+
1: "basic",
|
|
45
|
+
2: "common",
|
|
46
|
+
3: "uncommon",
|
|
47
|
+
4: "rare",
|
|
48
|
+
5: "mythic",
|
|
49
|
+
}
|
|
50
|
+
CACHE_SCHEMA_VERSION = 3
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CardDatabaseError(RuntimeError):
|
|
54
|
+
"""Base error for card database load, refresh, and parse failures.
|
|
55
|
+
Callers can catch this to show concise CLI diagnostics.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class CardDatabaseCacheMissingError(CardDatabaseError):
|
|
60
|
+
"""Raised when the card database cache has not been built yet.
|
|
61
|
+
Run refresh-data once before relying on fully offline lookups.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CardDatabaseCacheStaleError(CardDatabaseError):
|
|
66
|
+
"""Raised when the card cache schema needs a refresh.
|
|
67
|
+
Watch mode can rebuild it automatically from Scryfall bulk data.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class CardInfo:
|
|
73
|
+
"""Display metadata for one Arena card id.
|
|
74
|
+
Unknown markers use the same shape so callers never crash on misses.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
grp_id: int
|
|
78
|
+
name: str
|
|
79
|
+
colors: tuple[str, ...]
|
|
80
|
+
mana_value: float | None
|
|
81
|
+
rarity: str
|
|
82
|
+
types: tuple[str, ...]
|
|
83
|
+
mana_cost: str | None = None
|
|
84
|
+
produced_mana: tuple[str, ...] = ()
|
|
85
|
+
image_uri: str | None = None
|
|
86
|
+
unknown: bool = False
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def unknown_card(cls, *, grp_id: int) -> CardInfo:
|
|
90
|
+
"""Build an explicit unknown-card marker.
|
|
91
|
+
This keeps UI and replay paths total over arbitrary grpIds.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
return cls(
|
|
95
|
+
grp_id=grp_id,
|
|
96
|
+
name=f"Unknown card {grp_id}",
|
|
97
|
+
colors=(),
|
|
98
|
+
mana_value=None,
|
|
99
|
+
rarity="unknown",
|
|
100
|
+
types=("Unknown",),
|
|
101
|
+
mana_cost=None,
|
|
102
|
+
produced_mana=(),
|
|
103
|
+
image_uri=None,
|
|
104
|
+
unknown=True,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def from_json(cls, data: Mapping[str, Any]) -> CardInfo:
|
|
109
|
+
"""Load a card entry from Draftomen's cache format.
|
|
110
|
+
Cache parsing is strict so corrupted files fail loudly.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
return cls(
|
|
114
|
+
grp_id=_required_int(data.get("grp_id"), field_name="card.grp_id"),
|
|
115
|
+
name=_required_str(data.get("name"), field_name="card.name"),
|
|
116
|
+
colors=_string_tuple(data.get("colors"), field_name="card.colors"),
|
|
117
|
+
mana_value=_optional_float(
|
|
118
|
+
data.get("mana_value"),
|
|
119
|
+
field_name="card.mana_value",
|
|
120
|
+
),
|
|
121
|
+
rarity=_required_str(data.get("rarity"), field_name="card.rarity"),
|
|
122
|
+
types=_string_tuple(data.get("types"), field_name="card.types"),
|
|
123
|
+
mana_cost=_optional_str(data.get("mana_cost"), field_name="card.mana_cost"),
|
|
124
|
+
produced_mana=_string_tuple(
|
|
125
|
+
data.get("produced_mana", ()),
|
|
126
|
+
field_name="card.produced_mana",
|
|
127
|
+
),
|
|
128
|
+
image_uri=_optional_str(data.get("image_uri"), field_name="card.image_uri"),
|
|
129
|
+
unknown=bool(data.get("unknown", False)),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def to_json(self) -> dict[str, object]:
|
|
133
|
+
"""Convert this card entry to Draftomen's cache format.
|
|
134
|
+
The result intentionally stores only the fields the app needs.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
"grp_id": self.grp_id,
|
|
139
|
+
"name": self.name,
|
|
140
|
+
"colors": list(self.colors),
|
|
141
|
+
"mana_value": self.mana_value,
|
|
142
|
+
"rarity": self.rarity,
|
|
143
|
+
"types": list(self.types),
|
|
144
|
+
"mana_cost": self.mana_cost,
|
|
145
|
+
"produced_mana": list(self.produced_mana),
|
|
146
|
+
"image_uri": self.image_uri,
|
|
147
|
+
"unknown": self.unknown,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True, slots=True)
|
|
152
|
+
class CardMetadataSeed:
|
|
153
|
+
"""Set-scoped external metadata used to bridge grpIds to card names.
|
|
154
|
+
17Lands supplies these rows before Scryfall exposes arena_id values.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
grp_id: int
|
|
158
|
+
name: str
|
|
159
|
+
colors: tuple[str, ...]
|
|
160
|
+
rarity: str
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True, slots=True)
|
|
164
|
+
class CardDatabase:
|
|
165
|
+
"""Lookup table from Arena grpId to card metadata.
|
|
166
|
+
Missing grpIds return explicit unknown markers instead of raising.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
cards: dict[int, CardInfo]
|
|
170
|
+
image_uris_by_name: dict[str, str] = field(default_factory=dict)
|
|
171
|
+
generated_at: datetime | None = None
|
|
172
|
+
|
|
173
|
+
def __len__(self) -> int:
|
|
174
|
+
return len(self.cards)
|
|
175
|
+
|
|
176
|
+
def lookup(self, *, grp_id: int) -> CardInfo:
|
|
177
|
+
"""Return card metadata or an explicit unknown marker.
|
|
178
|
+
Lookup never raises for absent ids.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
return self.cards.get(grp_id, CardInfo.unknown_card(grp_id=grp_id))
|
|
182
|
+
|
|
183
|
+
def image_uri_for_name(self, *, name: str) -> str | None:
|
|
184
|
+
"""Return a cached Scryfall image URI by normalized card name.
|
|
185
|
+
This avoids per-card Scryfall API lookups while browsing in the TUI.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
return self.image_uris_by_name.get(_normalized_card_name(name=name))
|
|
189
|
+
|
|
190
|
+
def unresolved_grp_ids(self, *, grp_ids: Iterable[int]) -> tuple[int, ...]:
|
|
191
|
+
"""Return unique ids that still resolve to unknown markers.
|
|
192
|
+
UI code uses this to warn when metadata coverage is incomplete.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
seen: set[int] = set()
|
|
196
|
+
unresolved: list[int] = []
|
|
197
|
+
for grp_id in grp_ids:
|
|
198
|
+
if grp_id in seen:
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
seen.add(grp_id)
|
|
202
|
+
if self.lookup(grp_id=grp_id).unknown:
|
|
203
|
+
unresolved.append(grp_id)
|
|
204
|
+
|
|
205
|
+
return tuple(unresolved)
|
|
206
|
+
|
|
207
|
+
def to_json(self) -> dict[str, object]:
|
|
208
|
+
"""Convert the database to Draftomen's cache format.
|
|
209
|
+
Cards are sorted by grpId for stable cache diffs.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
"schema_version": CACHE_SCHEMA_VERSION,
|
|
214
|
+
"source": "scryfall-default-cards",
|
|
215
|
+
"generated_at": _utc_isoformat(value=self.generated_at),
|
|
216
|
+
"cards": {
|
|
217
|
+
str(grp_id): card.to_json()
|
|
218
|
+
for grp_id, card in sorted(self.cards.items())
|
|
219
|
+
},
|
|
220
|
+
"image_uris_by_name": dict(sorted(self.image_uris_by_name.items())),
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
@classmethod
|
|
224
|
+
def from_json(cls, data: Mapping[str, Any]) -> CardDatabase:
|
|
225
|
+
"""Load a card database from Draftomen's cache format.
|
|
226
|
+
Cache schema mismatches fail before any partial lookup is used.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
schema_version = _required_int(
|
|
230
|
+
data.get("schema_version"),
|
|
231
|
+
field_name="schema_version",
|
|
232
|
+
)
|
|
233
|
+
if schema_version != CACHE_SCHEMA_VERSION:
|
|
234
|
+
raise CardDatabaseCacheStaleError(
|
|
235
|
+
"Unsupported card database cache schema "
|
|
236
|
+
f"{schema_version}; expected {CACHE_SCHEMA_VERSION}."
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
cards_value = data.get("cards")
|
|
240
|
+
if not isinstance(cards_value, dict):
|
|
241
|
+
raise CardDatabaseError("Card database cache is missing cards object.")
|
|
242
|
+
|
|
243
|
+
cards: dict[int, CardInfo] = {}
|
|
244
|
+
for key, value in cards_value.items():
|
|
245
|
+
grp_id = _required_int(key, field_name="cards key")
|
|
246
|
+
if not isinstance(value, dict):
|
|
247
|
+
raise CardDatabaseError(f"Card cache entry {key!r} is not an object.")
|
|
248
|
+
|
|
249
|
+
card = CardInfo.from_json(data=value)
|
|
250
|
+
if card.grp_id != grp_id:
|
|
251
|
+
raise CardDatabaseError(
|
|
252
|
+
f"Card cache key {grp_id} does not match entry grp_id {card.grp_id}."
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
cards[grp_id] = card
|
|
256
|
+
|
|
257
|
+
return cls(
|
|
258
|
+
cards=cards,
|
|
259
|
+
image_uris_by_name=_image_uris_by_name_from_json(data=data),
|
|
260
|
+
generated_at=_optional_datetime(data.get("generated_at")),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _image_uris_by_name_from_json(*, data: Mapping[str, Any]) -> dict[str, str]:
|
|
265
|
+
value = data.get("image_uris_by_name", {})
|
|
266
|
+
if not isinstance(value, dict):
|
|
267
|
+
raise CardDatabaseError("Card database cache image index is not an object.")
|
|
268
|
+
|
|
269
|
+
image_uris: dict[str, str] = {}
|
|
270
|
+
for key, uri in value.items():
|
|
271
|
+
name = _required_str(key, field_name="image_uris_by_name key")
|
|
272
|
+
image_uri = _required_str(uri, field_name=f"image URI for {name}")
|
|
273
|
+
image_uris[_normalized_card_name(name=name)] = image_uri
|
|
274
|
+
|
|
275
|
+
return image_uris
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def card_database_cache_path(*, app_dir: PathInput | None = None) -> Path:
|
|
279
|
+
"""Return the default on-disk card database cache path.
|
|
280
|
+
The parent directory is not created until a refresh writes the cache.
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
root = Path(app_data_dir() if app_dir is None else app_dir)
|
|
284
|
+
return root / CARD_DATABASE_CACHE_FILENAME
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def load_card_database(
|
|
288
|
+
*,
|
|
289
|
+
app_dir: PathInput | None = None,
|
|
290
|
+
cache_path: PathInput | None = None,
|
|
291
|
+
) -> CardDatabase:
|
|
292
|
+
"""Load the card database cache without making network calls.
|
|
293
|
+
This is the fully offline path used after refresh-data has run once.
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
path = _cache_path(app_dir=app_dir, cache_path=cache_path)
|
|
297
|
+
if not path.exists():
|
|
298
|
+
raise CardDatabaseCacheMissingError(
|
|
299
|
+
f"Card database cache does not exist at {path}. Run refresh-data first."
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
304
|
+
except json.JSONDecodeError as error:
|
|
305
|
+
raise CardDatabaseError(f"Malformed card database cache {path}: {error}") from error
|
|
306
|
+
|
|
307
|
+
if not isinstance(data, dict):
|
|
308
|
+
raise CardDatabaseError(f"Malformed card database cache {path}: expected object.")
|
|
309
|
+
|
|
310
|
+
return CardDatabase.from_json(data=data)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def save_card_database(
|
|
314
|
+
database: CardDatabase,
|
|
315
|
+
*,
|
|
316
|
+
app_dir: PathInput | None = None,
|
|
317
|
+
cache_path: PathInput | None = None,
|
|
318
|
+
) -> Path:
|
|
319
|
+
"""Write a card database cache atomically.
|
|
320
|
+
The destination parent directory is created if needed.
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
path = _cache_path(app_dir=app_dir, cache_path=cache_path)
|
|
324
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
325
|
+
payload = json.dumps(database.to_json(), indent=2, sort_keys=True)
|
|
326
|
+
with tempfile.NamedTemporaryFile(
|
|
327
|
+
"w",
|
|
328
|
+
delete=False,
|
|
329
|
+
dir=path.parent,
|
|
330
|
+
encoding="utf-8",
|
|
331
|
+
) as temporary_file:
|
|
332
|
+
temporary_file.write(payload)
|
|
333
|
+
temporary_file.write("\n")
|
|
334
|
+
temporary_path = Path(temporary_file.name)
|
|
335
|
+
|
|
336
|
+
temporary_path.replace(path)
|
|
337
|
+
return path
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def refresh_card_database(
|
|
341
|
+
*,
|
|
342
|
+
app_dir: PathInput | None = None,
|
|
343
|
+
cache_path: PathInput | None = None,
|
|
344
|
+
bulk_file: PathInput | None = None,
|
|
345
|
+
arena_data_dir: PathInput | None = None,
|
|
346
|
+
allow_arena_fallback: bool = True,
|
|
347
|
+
timeout_seconds: int = HTTP_TIMEOUT_SECONDS,
|
|
348
|
+
) -> CardDatabase:
|
|
349
|
+
"""Build a grpId map from Scryfall and Arena local data.
|
|
350
|
+
Successful Scryfall refreshes atomically replace the canonical cache. Runtime
|
|
351
|
+
callers may use an Arena-only fallback without overwriting it; cache-building
|
|
352
|
+
callers can reject that non-cacheable result.
|
|
353
|
+
Passing bulk_file keeps tests and local fixtures completely offline.
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
cacheable = True
|
|
357
|
+
if bulk_file is None:
|
|
358
|
+
database, cacheable = _download_or_arena_card_database(
|
|
359
|
+
arena_data_dir=arena_data_dir,
|
|
360
|
+
timeout_seconds=timeout_seconds,
|
|
361
|
+
)
|
|
362
|
+
else:
|
|
363
|
+
database = build_card_database_from_bulk_file(path=bulk_file)
|
|
364
|
+
if arena_data_dir is not None:
|
|
365
|
+
database = augment_card_database_with_arena_data(
|
|
366
|
+
database,
|
|
367
|
+
arena_data_dir=arena_data_dir,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
if not cacheable and not allow_arena_fallback:
|
|
371
|
+
raise CardDatabaseError(
|
|
372
|
+
"Scryfall refresh did not produce a cacheable card metadata result."
|
|
373
|
+
)
|
|
374
|
+
if cacheable:
|
|
375
|
+
database = replace(database, generated_at=datetime.now(tz=UTC))
|
|
376
|
+
save_card_database(database, app_dir=app_dir, cache_path=cache_path)
|
|
377
|
+
return database
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def load_or_refresh_card_database(
|
|
381
|
+
*,
|
|
382
|
+
app_dir: PathInput | None = None,
|
|
383
|
+
cache_path: PathInput | None = None,
|
|
384
|
+
arena_data_dir: PathInput | None = None,
|
|
385
|
+
refresh: bool = False,
|
|
386
|
+
timeout_seconds: int = HTTP_TIMEOUT_SECONDS,
|
|
387
|
+
) -> CardDatabase:
|
|
388
|
+
"""Load cached card data, refreshing only when explicitly requested.
|
|
389
|
+
A missing cache triggers a runtime refresh, which may use uncached local
|
|
390
|
+
Arena metadata when Scryfall is unavailable.
|
|
391
|
+
"""
|
|
392
|
+
|
|
393
|
+
if refresh:
|
|
394
|
+
return refresh_card_database(
|
|
395
|
+
app_dir=app_dir,
|
|
396
|
+
cache_path=cache_path,
|
|
397
|
+
arena_data_dir=arena_data_dir,
|
|
398
|
+
timeout_seconds=timeout_seconds,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
database = load_card_database(app_dir=app_dir, cache_path=cache_path)
|
|
403
|
+
except (CardDatabaseCacheMissingError, CardDatabaseCacheStaleError):
|
|
404
|
+
return refresh_card_database(
|
|
405
|
+
app_dir=app_dir,
|
|
406
|
+
cache_path=cache_path,
|
|
407
|
+
arena_data_dir=arena_data_dir,
|
|
408
|
+
timeout_seconds=timeout_seconds,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
return augment_card_database_with_arena_data(
|
|
412
|
+
database,
|
|
413
|
+
arena_data_dir=arena_data_dir,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def download_scryfall_card_database(
|
|
418
|
+
*,
|
|
419
|
+
timeout_seconds: int = HTTP_TIMEOUT_SECONDS,
|
|
420
|
+
) -> CardDatabase:
|
|
421
|
+
"""Download Scryfall's default-cards bulk file and build a database.
|
|
422
|
+
Scryfall does not require an API key, only normal API headers.
|
|
423
|
+
"""
|
|
424
|
+
|
|
425
|
+
bulk_items = _fetch_bulk_data_items(timeout_seconds=timeout_seconds)
|
|
426
|
+
download_uri = _default_cards_download_uri(bulk_items=bulk_items)
|
|
427
|
+
return build_card_database_from_scryfall_cards(
|
|
428
|
+
cards=_iter_scryfall_jsonl_url(
|
|
429
|
+
url=download_uri,
|
|
430
|
+
timeout_seconds=timeout_seconds,
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def build_card_database_from_bulk_file(*, path: PathInput) -> CardDatabase:
|
|
436
|
+
"""Build the card database from a local Scryfall JSONL file.
|
|
437
|
+
Both plain .jsonl and Scryfall-style .jsonl.gz files are supported.
|
|
438
|
+
"""
|
|
439
|
+
|
|
440
|
+
bulk_path = Path(path)
|
|
441
|
+
with _open_text_bulk_file(path=bulk_path) as bulk_file:
|
|
442
|
+
return build_card_database_from_scryfall_cards(
|
|
443
|
+
cards=_iter_jsonl_objects(lines=bulk_file, source=str(bulk_path))
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def build_card_database_from_scryfall_cards(
|
|
448
|
+
*,
|
|
449
|
+
cards: Iterable[Mapping[str, Any]],
|
|
450
|
+
) -> CardDatabase:
|
|
451
|
+
"""Build the grpId map from Scryfall card objects.
|
|
452
|
+
Cards without arena_id are intentionally ignored.
|
|
453
|
+
"""
|
|
454
|
+
|
|
455
|
+
database_cards: dict[int, CardInfo] = {}
|
|
456
|
+
image_uris_by_name: dict[str, str] = {}
|
|
457
|
+
for card_object in cards:
|
|
458
|
+
_add_scryfall_image_uri_entries(
|
|
459
|
+
card=card_object,
|
|
460
|
+
image_uris_by_name=image_uris_by_name,
|
|
461
|
+
)
|
|
462
|
+
card = _card_info_from_scryfall(card=card_object)
|
|
463
|
+
if card is None:
|
|
464
|
+
continue
|
|
465
|
+
|
|
466
|
+
database_cards[card.grp_id] = card
|
|
467
|
+
|
|
468
|
+
return CardDatabase(cards=database_cards, image_uris_by_name=image_uris_by_name)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def build_card_database_from_arena_data_dir(*, path: PathInput) -> CardDatabase:
|
|
472
|
+
"""Build card metadata from MTG Arena's local data_cards/data_loc files.
|
|
473
|
+
This covers day-one Arena grpIds before Scryfall publishes arena_id values.
|
|
474
|
+
"""
|
|
475
|
+
|
|
476
|
+
data_dir = Path(path).expanduser()
|
|
477
|
+
cards_path, loc_path = _arena_data_file_pair(path=data_dir, required=True)
|
|
478
|
+
card_objects = _load_arena_json_array(path=cards_path, label="cards")
|
|
479
|
+
loc_objects = _load_arena_json_array(path=loc_path, label="localization")
|
|
480
|
+
localization = _arena_localization_map(items=loc_objects, source=str(loc_path))
|
|
481
|
+
return build_card_database_from_arena_cards(
|
|
482
|
+
cards=card_objects,
|
|
483
|
+
localization=localization,
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def build_card_database_from_arena_cards(
|
|
488
|
+
*,
|
|
489
|
+
cards: Iterable[Mapping[str, Any]],
|
|
490
|
+
localization: Mapping[int, str],
|
|
491
|
+
) -> CardDatabase:
|
|
492
|
+
"""Build the grpId map from Arena local card objects.
|
|
493
|
+
Arena local data is authoritative for the client grpIds present in logs.
|
|
494
|
+
"""
|
|
495
|
+
|
|
496
|
+
card_objects = tuple(cards)
|
|
497
|
+
cards_by_grp_id: dict[int, Mapping[str, Any]] = {}
|
|
498
|
+
for card_object in card_objects:
|
|
499
|
+
grp_id_value = card_object.get("grpid", card_object.get("grpId"))
|
|
500
|
+
if grp_id_value is None:
|
|
501
|
+
continue
|
|
502
|
+
|
|
503
|
+
grp_id = _required_int(grp_id_value, field_name="Arena card.grpid")
|
|
504
|
+
cards_by_grp_id[grp_id] = card_object
|
|
505
|
+
|
|
506
|
+
database_cards: dict[int, CardInfo] = {}
|
|
507
|
+
for card_object in card_objects:
|
|
508
|
+
card = _card_info_from_arena(
|
|
509
|
+
card=card_object,
|
|
510
|
+
localization=localization,
|
|
511
|
+
cards_by_grp_id=cards_by_grp_id,
|
|
512
|
+
)
|
|
513
|
+
if card is None:
|
|
514
|
+
continue
|
|
515
|
+
|
|
516
|
+
database_cards[card.grp_id] = card
|
|
517
|
+
|
|
518
|
+
return CardDatabase(cards=database_cards)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def find_default_arena_data_dir() -> Path | None:
|
|
522
|
+
"""Return the first default MTG Arena data dir with card metadata files.
|
|
523
|
+
The function is best-effort and returns None when Arena is not installed.
|
|
524
|
+
"""
|
|
525
|
+
|
|
526
|
+
for candidate in _default_arena_data_dir_candidates():
|
|
527
|
+
if _arena_data_file_pair(path=candidate, required=False) is not None:
|
|
528
|
+
return candidate
|
|
529
|
+
|
|
530
|
+
return None
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def augment_card_database_with_arena_data(
|
|
534
|
+
database: CardDatabase,
|
|
535
|
+
*,
|
|
536
|
+
arena_data_dir: PathInput | None = None,
|
|
537
|
+
) -> CardDatabase:
|
|
538
|
+
"""Overlay local Arena card data when it is available.
|
|
539
|
+
Local data wins because it is the source of grpIds emitted by Player.log.
|
|
540
|
+
"""
|
|
541
|
+
|
|
542
|
+
arena_database = _load_arena_card_database_if_available(
|
|
543
|
+
arena_data_dir=arena_data_dir,
|
|
544
|
+
)
|
|
545
|
+
if arena_database is None:
|
|
546
|
+
return database
|
|
547
|
+
|
|
548
|
+
return _merge_card_databases(base=database, overlay=arena_database)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def augment_card_database_with_mtgjson_set(
|
|
552
|
+
database: CardDatabase,
|
|
553
|
+
*,
|
|
554
|
+
set_code: str,
|
|
555
|
+
seeds: Iterable[CardMetadataSeed],
|
|
556
|
+
timeout_seconds: int = HTTP_TIMEOUT_SECONDS,
|
|
557
|
+
mtgjson_cards: Iterable[Mapping[str, Any]] | None = None,
|
|
558
|
+
) -> CardDatabase:
|
|
559
|
+
"""Resolve missing grpIds by matching 17Lands names to MTGJSON set data.
|
|
560
|
+
This covers new sets whose Scryfall records do not yet expose arena_id.
|
|
561
|
+
"""
|
|
562
|
+
|
|
563
|
+
missing_seeds = tuple(
|
|
564
|
+
seed for seed in seeds if database.lookup(grp_id=seed.grp_id).unknown
|
|
565
|
+
)
|
|
566
|
+
if not missing_seeds:
|
|
567
|
+
return database
|
|
568
|
+
|
|
569
|
+
card_objects = tuple(
|
|
570
|
+
download_mtgjson_set_cards(
|
|
571
|
+
set_code=set_code,
|
|
572
|
+
timeout_seconds=timeout_seconds,
|
|
573
|
+
)
|
|
574
|
+
if mtgjson_cards is None
|
|
575
|
+
else mtgjson_cards
|
|
576
|
+
)
|
|
577
|
+
cards_by_uuid = _mtgjson_cards_by_uuid(cards=card_objects)
|
|
578
|
+
cards_by_name = _mtgjson_cards_by_name(cards=card_objects)
|
|
579
|
+
card_indices = {id(card): index for index, card in enumerate(card_objects)}
|
|
580
|
+
cards = dict(database.cards)
|
|
581
|
+
inferred_offset = _mtgjson_arena_id_offset(
|
|
582
|
+
seeds=missing_seeds,
|
|
583
|
+
cards_by_name=cards_by_name,
|
|
584
|
+
card_indices=card_indices,
|
|
585
|
+
)
|
|
586
|
+
if inferred_offset is not None:
|
|
587
|
+
_add_mtgjson_cards_by_inferred_arena_order(
|
|
588
|
+
cards=cards,
|
|
589
|
+
card_objects=card_objects,
|
|
590
|
+
cards_by_uuid=cards_by_uuid,
|
|
591
|
+
arena_id_offset=inferred_offset,
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
for seed in missing_seeds:
|
|
595
|
+
mtgjson_card = _mtgjson_card_for_seed(seed=seed, cards_by_name=cards_by_name)
|
|
596
|
+
if mtgjson_card is None:
|
|
597
|
+
cards[seed.grp_id] = _card_info_from_metadata_seed(seed=seed)
|
|
598
|
+
continue
|
|
599
|
+
|
|
600
|
+
cards[seed.grp_id] = _card_info_from_mtgjson(
|
|
601
|
+
card=mtgjson_card,
|
|
602
|
+
seed=seed,
|
|
603
|
+
cards_by_uuid=cards_by_uuid,
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
return replace(database, cards=cards)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def download_mtgjson_set_cards(
|
|
610
|
+
*,
|
|
611
|
+
set_code: str,
|
|
612
|
+
timeout_seconds: int = HTTP_TIMEOUT_SECONDS,
|
|
613
|
+
) -> tuple[Mapping[str, Any], ...]:
|
|
614
|
+
"""Download one MTGJSON set file and return its card objects.
|
|
615
|
+
MTGJSON includes current-set card names, mana values, and type lines.
|
|
616
|
+
"""
|
|
617
|
+
|
|
618
|
+
url = MTGJSON_SET_URL_TEMPLATE.format(
|
|
619
|
+
set_code=urllib.parse.quote(set_code.upper()),
|
|
620
|
+
)
|
|
621
|
+
request = _request(url=url)
|
|
622
|
+
try:
|
|
623
|
+
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
|
|
624
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
625
|
+
except urllib.error.URLError as error:
|
|
626
|
+
raise CardDatabaseError(f"Failed to query MTGJSON set metadata: {error}") from error
|
|
627
|
+
except json.JSONDecodeError as error:
|
|
628
|
+
raise CardDatabaseError(f"Malformed MTGJSON set metadata: {error}") from error
|
|
629
|
+
|
|
630
|
+
if not isinstance(payload, dict):
|
|
631
|
+
raise CardDatabaseError("Malformed MTGJSON set metadata: expected object.")
|
|
632
|
+
|
|
633
|
+
data = payload.get("data")
|
|
634
|
+
if not isinstance(data, dict):
|
|
635
|
+
raise CardDatabaseError("Malformed MTGJSON set metadata: missing data object.")
|
|
636
|
+
|
|
637
|
+
cards_value = data.get("cards")
|
|
638
|
+
if not isinstance(cards_value, list):
|
|
639
|
+
raise CardDatabaseError("Malformed MTGJSON set metadata: missing cards list.")
|
|
640
|
+
|
|
641
|
+
cards: list[Mapping[str, Any]] = []
|
|
642
|
+
for item in cards_value:
|
|
643
|
+
if not isinstance(item, dict):
|
|
644
|
+
raise CardDatabaseError("Malformed MTGJSON set metadata: card is not object.")
|
|
645
|
+
|
|
646
|
+
cards.append(item)
|
|
647
|
+
|
|
648
|
+
return tuple(cards)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _mtgjson_cards_by_uuid(
|
|
652
|
+
*,
|
|
653
|
+
cards: Iterable[Mapping[str, Any]],
|
|
654
|
+
) -> dict[str, Mapping[str, Any]]:
|
|
655
|
+
cards_by_uuid: dict[str, Mapping[str, Any]] = {}
|
|
656
|
+
for card in cards:
|
|
657
|
+
uuid = card.get("uuid")
|
|
658
|
+
if isinstance(uuid, str) and uuid:
|
|
659
|
+
cards_by_uuid[uuid] = card
|
|
660
|
+
|
|
661
|
+
return cards_by_uuid
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _mtgjson_cards_by_name(
|
|
665
|
+
*,
|
|
666
|
+
cards: Iterable[Mapping[str, Any]],
|
|
667
|
+
) -> dict[str, list[Mapping[str, Any]]]:
|
|
668
|
+
cards_by_name: dict[str, list[Mapping[str, Any]]] = {}
|
|
669
|
+
for card in cards:
|
|
670
|
+
for name in _mtgjson_card_names(card=card):
|
|
671
|
+
cards_by_name.setdefault(_normalized_card_name(name=name), []).append(card)
|
|
672
|
+
|
|
673
|
+
return cards_by_name
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _mtgjson_card_names(*, card: Mapping[str, Any]) -> tuple[str, ...]:
|
|
677
|
+
names: list[str] = []
|
|
678
|
+
name = card.get("name")
|
|
679
|
+
if isinstance(name, str) and name:
|
|
680
|
+
names.append(name)
|
|
681
|
+
|
|
682
|
+
face_name = card.get("faceName")
|
|
683
|
+
if isinstance(face_name, str) and face_name:
|
|
684
|
+
names.append(face_name)
|
|
685
|
+
|
|
686
|
+
return tuple(dict.fromkeys(names))
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _mtgjson_card_for_seed(
|
|
690
|
+
*,
|
|
691
|
+
seed: CardMetadataSeed,
|
|
692
|
+
cards_by_name: Mapping[str, list[Mapping[str, Any]]],
|
|
693
|
+
) -> Mapping[str, Any] | None:
|
|
694
|
+
candidates = cards_by_name.get(_normalized_card_name(name=seed.name), [])
|
|
695
|
+
if not candidates:
|
|
696
|
+
return None
|
|
697
|
+
|
|
698
|
+
return min(candidates, key=_mtgjson_card_sort_key)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _mtgjson_arena_id_offset(
|
|
702
|
+
*,
|
|
703
|
+
seeds: Iterable[CardMetadataSeed],
|
|
704
|
+
cards_by_name: Mapping[str, list[Mapping[str, Any]]],
|
|
705
|
+
card_indices: Mapping[int, int],
|
|
706
|
+
) -> int | None:
|
|
707
|
+
offset_counts: dict[int, int] = {}
|
|
708
|
+
for seed in seeds:
|
|
709
|
+
card = _mtgjson_card_for_seed(seed=seed, cards_by_name=cards_by_name)
|
|
710
|
+
if card is None:
|
|
711
|
+
continue
|
|
712
|
+
|
|
713
|
+
index = card_indices.get(id(card))
|
|
714
|
+
if index is None:
|
|
715
|
+
continue
|
|
716
|
+
|
|
717
|
+
offset = seed.grp_id - index
|
|
718
|
+
offset_counts[offset] = offset_counts.get(offset, 0) + 1
|
|
719
|
+
|
|
720
|
+
if not offset_counts:
|
|
721
|
+
return None
|
|
722
|
+
|
|
723
|
+
return max(offset_counts, key=lambda offset: (offset_counts[offset], -offset))
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _add_mtgjson_cards_by_inferred_arena_order(
|
|
727
|
+
*,
|
|
728
|
+
cards: dict[int, CardInfo],
|
|
729
|
+
card_objects: tuple[Mapping[str, Any], ...],
|
|
730
|
+
cards_by_uuid: Mapping[str, Mapping[str, Any]],
|
|
731
|
+
arena_id_offset: int,
|
|
732
|
+
) -> None:
|
|
733
|
+
for index, card in enumerate(card_objects):
|
|
734
|
+
if not _mtgjson_card_is_arena_available(card=card):
|
|
735
|
+
continue
|
|
736
|
+
|
|
737
|
+
grp_id = arena_id_offset + index
|
|
738
|
+
if grp_id in cards and not cards[grp_id].unknown:
|
|
739
|
+
continue
|
|
740
|
+
|
|
741
|
+
cards[grp_id] = _card_info_from_mtgjson(
|
|
742
|
+
card=card,
|
|
743
|
+
seed=_metadata_seed_from_mtgjson_card(grp_id=grp_id, card=card),
|
|
744
|
+
cards_by_uuid=cards_by_uuid,
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _mtgjson_card_is_arena_available(*, card: Mapping[str, Any]) -> bool:
|
|
749
|
+
availability = card.get("availability")
|
|
750
|
+
return isinstance(availability, list) and "arena" in availability
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _metadata_seed_from_mtgjson_card(
|
|
754
|
+
*,
|
|
755
|
+
grp_id: int,
|
|
756
|
+
card: Mapping[str, Any],
|
|
757
|
+
) -> CardMetadataSeed:
|
|
758
|
+
name = _required_str(card.get("name"), field_name=f"MTGJSON card {grp_id}.name")
|
|
759
|
+
return CardMetadataSeed(
|
|
760
|
+
grp_id=grp_id,
|
|
761
|
+
name=name,
|
|
762
|
+
colors=_mtgjson_colors(card=card, field_name=name),
|
|
763
|
+
rarity=_required_str(
|
|
764
|
+
card.get("rarity", "unknown"),
|
|
765
|
+
field_name=f"MTGJSON card {name}.rarity",
|
|
766
|
+
),
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _mtgjson_card_sort_key(card: Mapping[str, Any]) -> tuple[int, int, str]:
|
|
771
|
+
availability = card.get("availability")
|
|
772
|
+
available_on_arena = isinstance(availability, list) and "arena" in availability
|
|
773
|
+
side = card.get("side")
|
|
774
|
+
is_front_or_single = side in (None, "", "a")
|
|
775
|
+
uuid = card.get("uuid")
|
|
776
|
+
return (
|
|
777
|
+
0 if available_on_arena else 1,
|
|
778
|
+
0 if is_front_or_single else 1,
|
|
779
|
+
uuid if isinstance(uuid, str) else "",
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _card_info_from_metadata_seed(*, seed: CardMetadataSeed) -> CardInfo:
|
|
784
|
+
return CardInfo(
|
|
785
|
+
grp_id=seed.grp_id,
|
|
786
|
+
name=seed.name,
|
|
787
|
+
colors=seed.colors,
|
|
788
|
+
mana_value=None,
|
|
789
|
+
rarity=seed.rarity,
|
|
790
|
+
types=("Unknown",),
|
|
791
|
+
unknown=True,
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _card_info_from_mtgjson(
|
|
796
|
+
*,
|
|
797
|
+
card: Mapping[str, Any],
|
|
798
|
+
seed: CardMetadataSeed,
|
|
799
|
+
cards_by_uuid: Mapping[str, Mapping[str, Any]],
|
|
800
|
+
) -> CardInfo:
|
|
801
|
+
faces = _mtgjson_related_faces(card=card, cards_by_uuid=cards_by_uuid)
|
|
802
|
+
type_lines = tuple(dict.fromkeys(
|
|
803
|
+
_required_str(
|
|
804
|
+
face.get("type"),
|
|
805
|
+
field_name=f"MTGJSON card {seed.name}.type",
|
|
806
|
+
)
|
|
807
|
+
for face in faces
|
|
808
|
+
))
|
|
809
|
+
mana_cost = _mtgjson_combined_mana_cost(faces=faces)
|
|
810
|
+
return CardInfo(
|
|
811
|
+
grp_id=seed.grp_id,
|
|
812
|
+
name=seed.name,
|
|
813
|
+
colors=seed.colors or _mtgjson_colors(card=card, field_name=seed.name),
|
|
814
|
+
mana_value=_required_float(
|
|
815
|
+
card.get("manaValue", card.get("mana_value")),
|
|
816
|
+
field_name=f"MTGJSON card {seed.name}.manaValue",
|
|
817
|
+
),
|
|
818
|
+
rarity=_required_str(
|
|
819
|
+
card.get("rarity", seed.rarity),
|
|
820
|
+
field_name=f"MTGJSON card {seed.name}.rarity",
|
|
821
|
+
),
|
|
822
|
+
types=type_lines,
|
|
823
|
+
mana_cost=mana_cost,
|
|
824
|
+
produced_mana=_mtgjson_produced_mana(card=card, field_name=seed.name),
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _mtgjson_related_faces(
|
|
829
|
+
*,
|
|
830
|
+
card: Mapping[str, Any],
|
|
831
|
+
cards_by_uuid: Mapping[str, Mapping[str, Any]],
|
|
832
|
+
) -> tuple[Mapping[str, Any], ...]:
|
|
833
|
+
faces = [card]
|
|
834
|
+
other_faces_value = card.get("otherFaceIds", ())
|
|
835
|
+
if isinstance(other_faces_value, list):
|
|
836
|
+
for face_uuid in other_faces_value:
|
|
837
|
+
if not isinstance(face_uuid, str):
|
|
838
|
+
continue
|
|
839
|
+
|
|
840
|
+
face = cards_by_uuid.get(face_uuid)
|
|
841
|
+
if face is not None:
|
|
842
|
+
faces.append(face)
|
|
843
|
+
|
|
844
|
+
return tuple(faces)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _mtgjson_combined_mana_cost(*, faces: tuple[Mapping[str, Any], ...]) -> str | None:
|
|
848
|
+
costs = tuple(
|
|
849
|
+
cost
|
|
850
|
+
for cost in (_optional_mtgjson_mana_cost(face.get("manaCost")) for face in faces)
|
|
851
|
+
if cost is not None
|
|
852
|
+
)
|
|
853
|
+
if not costs:
|
|
854
|
+
return None
|
|
855
|
+
|
|
856
|
+
return " // ".join(costs)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _optional_mtgjson_mana_cost(value: Any) -> str | None:
|
|
860
|
+
if value is None:
|
|
861
|
+
return None
|
|
862
|
+
|
|
863
|
+
return _required_str(value, field_name="MTGJSON card.manaCost")
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _mtgjson_colors(*, card: Mapping[str, Any], field_name: str) -> tuple[str, ...]:
|
|
867
|
+
colors_value = card.get("colors")
|
|
868
|
+
if colors_value is not None:
|
|
869
|
+
return _color_tuple(colors_value, field_name=f"MTGJSON card {field_name}.colors")
|
|
870
|
+
|
|
871
|
+
return _color_tuple(
|
|
872
|
+
card.get("colorIdentity", ()),
|
|
873
|
+
field_name=f"MTGJSON card {field_name}.colorIdentity",
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _mtgjson_produced_mana(
|
|
878
|
+
*,
|
|
879
|
+
card: Mapping[str, Any],
|
|
880
|
+
field_name: str,
|
|
881
|
+
) -> tuple[str, ...]:
|
|
882
|
+
produced_value = card.get("producedMana")
|
|
883
|
+
if produced_value is None:
|
|
884
|
+
return ()
|
|
885
|
+
|
|
886
|
+
return _produced_mana_tuple(
|
|
887
|
+
produced_value,
|
|
888
|
+
field_name=f"MTGJSON card {field_name}.producedMana",
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _normalized_card_name(*, name: str) -> str:
|
|
893
|
+
return " ".join(name.casefold().replace("’", "'").split())
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _cache_path(
|
|
897
|
+
*,
|
|
898
|
+
app_dir: PathInput | None,
|
|
899
|
+
cache_path: PathInput | None,
|
|
900
|
+
) -> Path:
|
|
901
|
+
if cache_path is not None:
|
|
902
|
+
return Path(cache_path)
|
|
903
|
+
|
|
904
|
+
return card_database_cache_path(app_dir=app_dir)
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _card_info_from_scryfall(*, card: Mapping[str, Any]) -> CardInfo | None:
|
|
908
|
+
arena_id = card.get("arena_id")
|
|
909
|
+
if arena_id is None:
|
|
910
|
+
return None
|
|
911
|
+
|
|
912
|
+
grp_id = _required_int(arena_id, field_name="arena_id")
|
|
913
|
+
name = _required_str(card.get("name"), field_name=f"card {grp_id}.name")
|
|
914
|
+
mana_value = _required_float(card.get("cmc"), field_name=f"card {grp_id}.cmc")
|
|
915
|
+
rarity = _required_str(card.get("rarity"), field_name=f"card {grp_id}.rarity")
|
|
916
|
+
return CardInfo(
|
|
917
|
+
grp_id=grp_id,
|
|
918
|
+
name=name,
|
|
919
|
+
colors=_card_colors(card=card, grp_id=grp_id),
|
|
920
|
+
mana_value=mana_value,
|
|
921
|
+
rarity=rarity,
|
|
922
|
+
types=_card_types(card=card, grp_id=grp_id),
|
|
923
|
+
mana_cost=_card_mana_cost(card=card, grp_id=grp_id),
|
|
924
|
+
produced_mana=_card_produced_mana(card=card, grp_id=grp_id),
|
|
925
|
+
image_uri=_card_image_uri(card=card),
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _card_info_from_arena(
|
|
930
|
+
*,
|
|
931
|
+
card: Mapping[str, Any],
|
|
932
|
+
localization: Mapping[int, str],
|
|
933
|
+
cards_by_grp_id: Mapping[int, Mapping[str, Any]],
|
|
934
|
+
) -> CardInfo | None:
|
|
935
|
+
grp_id_value = card.get("grpid", card.get("grpId"))
|
|
936
|
+
if grp_id_value is None:
|
|
937
|
+
return None
|
|
938
|
+
|
|
939
|
+
grp_id = _required_int(grp_id_value, field_name="Arena card.grpid")
|
|
940
|
+
linked_faces = _arena_linked_face_cards(
|
|
941
|
+
card=card,
|
|
942
|
+
cards_by_grp_id=cards_by_grp_id,
|
|
943
|
+
)
|
|
944
|
+
type_line = _arena_combined_type_line(
|
|
945
|
+
card=card,
|
|
946
|
+
linked_faces=linked_faces,
|
|
947
|
+
localization=localization,
|
|
948
|
+
grp_id=grp_id,
|
|
949
|
+
)
|
|
950
|
+
return CardInfo(
|
|
951
|
+
grp_id=grp_id,
|
|
952
|
+
name=_arena_localized_text(
|
|
953
|
+
localization=localization,
|
|
954
|
+
text_id=card.get("titleId"),
|
|
955
|
+
field_name=f"Arena card {grp_id}.titleId",
|
|
956
|
+
),
|
|
957
|
+
colors=_arena_card_colors(
|
|
958
|
+
card=card,
|
|
959
|
+
linked_faces=linked_faces,
|
|
960
|
+
grp_id=grp_id,
|
|
961
|
+
),
|
|
962
|
+
mana_value=_required_float(
|
|
963
|
+
card.get("cmc"),
|
|
964
|
+
field_name=f"Arena card {grp_id}.cmc",
|
|
965
|
+
),
|
|
966
|
+
rarity=_arena_card_rarity(card=card, grp_id=grp_id),
|
|
967
|
+
types=(type_line,),
|
|
968
|
+
mana_cost=_arena_card_mana_cost(card=card, linked_faces=linked_faces),
|
|
969
|
+
produced_mana=_arena_card_produced_mana(
|
|
970
|
+
card=card,
|
|
971
|
+
primary_type_line=_arena_type_line(
|
|
972
|
+
card=card,
|
|
973
|
+
localization=localization,
|
|
974
|
+
grp_id=grp_id,
|
|
975
|
+
),
|
|
976
|
+
grp_id=grp_id,
|
|
977
|
+
),
|
|
978
|
+
)
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def _download_or_arena_card_database(
|
|
982
|
+
*,
|
|
983
|
+
arena_data_dir: PathInput | None,
|
|
984
|
+
timeout_seconds: int,
|
|
985
|
+
) -> tuple[CardDatabase, bool]:
|
|
986
|
+
"""Return the current-run database and whether it is safe to cache canonically."""
|
|
987
|
+
|
|
988
|
+
try:
|
|
989
|
+
database = download_scryfall_card_database(timeout_seconds=timeout_seconds)
|
|
990
|
+
except CardDatabaseError:
|
|
991
|
+
arena_database = _load_arena_card_database_if_available(
|
|
992
|
+
arena_data_dir=arena_data_dir,
|
|
993
|
+
)
|
|
994
|
+
if arena_database is None:
|
|
995
|
+
raise
|
|
996
|
+
|
|
997
|
+
return arena_database, False
|
|
998
|
+
|
|
999
|
+
return (
|
|
1000
|
+
augment_card_database_with_arena_data(
|
|
1001
|
+
database,
|
|
1002
|
+
arena_data_dir=arena_data_dir,
|
|
1003
|
+
),
|
|
1004
|
+
True,
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _load_arena_card_database_if_available(
|
|
1009
|
+
*,
|
|
1010
|
+
arena_data_dir: PathInput | None,
|
|
1011
|
+
) -> CardDatabase | None:
|
|
1012
|
+
if arena_data_dir is not None:
|
|
1013
|
+
return build_card_database_from_arena_data_dir(path=arena_data_dir)
|
|
1014
|
+
|
|
1015
|
+
default_data_dir = find_default_arena_data_dir()
|
|
1016
|
+
if default_data_dir is None:
|
|
1017
|
+
return None
|
|
1018
|
+
|
|
1019
|
+
return build_card_database_from_arena_data_dir(path=default_data_dir)
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def _merge_card_databases(
|
|
1023
|
+
*,
|
|
1024
|
+
base: CardDatabase,
|
|
1025
|
+
overlay: CardDatabase,
|
|
1026
|
+
) -> CardDatabase:
|
|
1027
|
+
cards = dict(base.cards)
|
|
1028
|
+
for grp_id, overlay_card in overlay.cards.items():
|
|
1029
|
+
base_card = cards.get(grp_id)
|
|
1030
|
+
if overlay_card.image_uri is None and base_card is not None:
|
|
1031
|
+
overlay_card = replace(overlay_card, image_uri=base_card.image_uri)
|
|
1032
|
+
|
|
1033
|
+
cards[grp_id] = overlay_card
|
|
1034
|
+
|
|
1035
|
+
image_uris_by_name = dict(base.image_uris_by_name)
|
|
1036
|
+
image_uris_by_name.update(overlay.image_uris_by_name)
|
|
1037
|
+
return replace(
|
|
1038
|
+
base,
|
|
1039
|
+
cards=cards,
|
|
1040
|
+
image_uris_by_name=image_uris_by_name,
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
SCRYFALL_IMAGE_URI_KEYS = (
|
|
1045
|
+
"normal",
|
|
1046
|
+
"large",
|
|
1047
|
+
"small",
|
|
1048
|
+
"png",
|
|
1049
|
+
"border_crop",
|
|
1050
|
+
"art_crop",
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def _add_scryfall_image_uri_entries(
|
|
1055
|
+
*,
|
|
1056
|
+
card: Mapping[str, Any],
|
|
1057
|
+
image_uris_by_name: dict[str, str],
|
|
1058
|
+
) -> None:
|
|
1059
|
+
image_uri = _card_image_uri(card=card)
|
|
1060
|
+
if image_uri is None:
|
|
1061
|
+
return
|
|
1062
|
+
|
|
1063
|
+
for name in _scryfall_card_image_names(card=card):
|
|
1064
|
+
image_uris_by_name.setdefault(_normalized_card_name(name=name), image_uri)
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _scryfall_card_image_names(*, card: Mapping[str, Any]) -> tuple[str, ...]:
|
|
1068
|
+
names: list[str] = []
|
|
1069
|
+
name = card.get("name")
|
|
1070
|
+
if isinstance(name, str) and name:
|
|
1071
|
+
names.append(name)
|
|
1072
|
+
|
|
1073
|
+
faces_value = card.get("card_faces")
|
|
1074
|
+
if isinstance(faces_value, list):
|
|
1075
|
+
for face in faces_value:
|
|
1076
|
+
if not isinstance(face, dict):
|
|
1077
|
+
continue
|
|
1078
|
+
|
|
1079
|
+
face_name = face.get("name")
|
|
1080
|
+
if isinstance(face_name, str) and face_name:
|
|
1081
|
+
names.append(face_name)
|
|
1082
|
+
|
|
1083
|
+
return tuple(dict.fromkeys(names))
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def _card_image_uri(*, card: Mapping[str, Any]) -> str | None:
|
|
1087
|
+
image_uri = _image_uri_from_scryfall_image_uris(card.get("image_uris"))
|
|
1088
|
+
if image_uri is not None:
|
|
1089
|
+
return image_uri
|
|
1090
|
+
|
|
1091
|
+
faces_value = card.get("card_faces")
|
|
1092
|
+
if not isinstance(faces_value, list):
|
|
1093
|
+
return None
|
|
1094
|
+
|
|
1095
|
+
for face in faces_value:
|
|
1096
|
+
if not isinstance(face, dict):
|
|
1097
|
+
continue
|
|
1098
|
+
|
|
1099
|
+
image_uri = _image_uri_from_scryfall_image_uris(face.get("image_uris"))
|
|
1100
|
+
if image_uri is not None:
|
|
1101
|
+
return image_uri
|
|
1102
|
+
|
|
1103
|
+
return None
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def _image_uri_from_scryfall_image_uris(value: Any) -> str | None:
|
|
1107
|
+
if not isinstance(value, dict):
|
|
1108
|
+
return None
|
|
1109
|
+
|
|
1110
|
+
for key in SCRYFALL_IMAGE_URI_KEYS:
|
|
1111
|
+
uri = value.get(key)
|
|
1112
|
+
if isinstance(uri, str) and uri:
|
|
1113
|
+
return uri
|
|
1114
|
+
|
|
1115
|
+
return None
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def _card_colors(*, card: Mapping[str, Any], grp_id: int) -> tuple[str, ...]:
|
|
1119
|
+
colors_value = card.get("colors")
|
|
1120
|
+
if colors_value is not None:
|
|
1121
|
+
return _color_tuple(colors_value, field_name=f"card {grp_id}.colors")
|
|
1122
|
+
|
|
1123
|
+
face_colors: list[str] = []
|
|
1124
|
+
faces_value = card.get("card_faces")
|
|
1125
|
+
if isinstance(faces_value, list):
|
|
1126
|
+
for face in faces_value:
|
|
1127
|
+
if not isinstance(face, dict):
|
|
1128
|
+
continue
|
|
1129
|
+
|
|
1130
|
+
face_colors.extend(
|
|
1131
|
+
_color_tuple(
|
|
1132
|
+
face.get("colors", ()),
|
|
1133
|
+
field_name=f"card {grp_id}.card_faces[].colors",
|
|
1134
|
+
)
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
return _ordered_unique_colors(colors=face_colors)
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def _card_types(*, card: Mapping[str, Any], grp_id: int) -> tuple[str, ...]:
|
|
1141
|
+
type_line_value = card.get("type_line")
|
|
1142
|
+
if isinstance(type_line_value, str) and type_line_value:
|
|
1143
|
+
return (type_line_value,)
|
|
1144
|
+
|
|
1145
|
+
faces_value = card.get("card_faces")
|
|
1146
|
+
face_types: list[str] = []
|
|
1147
|
+
if isinstance(faces_value, list):
|
|
1148
|
+
for face in faces_value:
|
|
1149
|
+
if not isinstance(face, dict):
|
|
1150
|
+
continue
|
|
1151
|
+
|
|
1152
|
+
face_type = face.get("type_line")
|
|
1153
|
+
if isinstance(face_type, str) and face_type:
|
|
1154
|
+
face_types.append(face_type)
|
|
1155
|
+
|
|
1156
|
+
if face_types:
|
|
1157
|
+
return tuple(face_types)
|
|
1158
|
+
|
|
1159
|
+
raise CardDatabaseError(f"Scryfall card {grp_id} is missing type_line.")
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _card_mana_cost(*, card: Mapping[str, Any], grp_id: int) -> str | None:
|
|
1163
|
+
mana_cost_value = card.get("mana_cost")
|
|
1164
|
+
if isinstance(mana_cost_value, str) and mana_cost_value:
|
|
1165
|
+
return mana_cost_value
|
|
1166
|
+
|
|
1167
|
+
faces_value = card.get("card_faces")
|
|
1168
|
+
face_costs: list[str] = []
|
|
1169
|
+
if isinstance(faces_value, list):
|
|
1170
|
+
for face in faces_value:
|
|
1171
|
+
if not isinstance(face, dict):
|
|
1172
|
+
continue
|
|
1173
|
+
|
|
1174
|
+
face_cost = face.get("mana_cost")
|
|
1175
|
+
if isinstance(face_cost, str) and face_cost:
|
|
1176
|
+
face_costs.append(face_cost)
|
|
1177
|
+
|
|
1178
|
+
if face_costs:
|
|
1179
|
+
return " // ".join(face_costs)
|
|
1180
|
+
|
|
1181
|
+
return None
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _card_produced_mana(*, card: Mapping[str, Any], grp_id: int) -> tuple[str, ...]:
|
|
1185
|
+
produced_value = card.get("produced_mana")
|
|
1186
|
+
if produced_value is not None:
|
|
1187
|
+
return _produced_mana_tuple(
|
|
1188
|
+
produced_value,
|
|
1189
|
+
field_name=f"card {grp_id}.produced_mana",
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
faces_value = card.get("card_faces")
|
|
1193
|
+
face_mana: list[str] = []
|
|
1194
|
+
if isinstance(faces_value, list):
|
|
1195
|
+
for face in faces_value:
|
|
1196
|
+
if not isinstance(face, dict):
|
|
1197
|
+
continue
|
|
1198
|
+
|
|
1199
|
+
face_value = face.get("produced_mana")
|
|
1200
|
+
if face_value is None:
|
|
1201
|
+
continue
|
|
1202
|
+
|
|
1203
|
+
face_mana.extend(
|
|
1204
|
+
_produced_mana_tuple(
|
|
1205
|
+
face_value,
|
|
1206
|
+
field_name=f"card {grp_id}.card_faces[].produced_mana",
|
|
1207
|
+
)
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
return _ordered_unique_colors(colors=face_mana)
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def _arena_linked_face_cards(
|
|
1214
|
+
*,
|
|
1215
|
+
card: Mapping[str, Any],
|
|
1216
|
+
cards_by_grp_id: Mapping[int, Mapping[str, Any]],
|
|
1217
|
+
) -> tuple[Mapping[str, Any], ...]:
|
|
1218
|
+
linked_faces_value = card.get("linkedFaces", ())
|
|
1219
|
+
if linked_faces_value is None:
|
|
1220
|
+
return ()
|
|
1221
|
+
|
|
1222
|
+
if isinstance(linked_faces_value, (str, bytes)) or not isinstance(
|
|
1223
|
+
linked_faces_value,
|
|
1224
|
+
Iterable,
|
|
1225
|
+
):
|
|
1226
|
+
raise CardDatabaseError("Missing or invalid Arena card.linkedFaces list.")
|
|
1227
|
+
|
|
1228
|
+
linked_faces: list[Mapping[str, Any]] = []
|
|
1229
|
+
for linked_face_value in linked_faces_value:
|
|
1230
|
+
linked_grp_id = _required_int(
|
|
1231
|
+
linked_face_value,
|
|
1232
|
+
field_name="Arena card.linkedFaces[]",
|
|
1233
|
+
)
|
|
1234
|
+
linked_card = cards_by_grp_id.get(linked_grp_id)
|
|
1235
|
+
if linked_card is not None:
|
|
1236
|
+
linked_faces.append(linked_card)
|
|
1237
|
+
|
|
1238
|
+
return tuple(linked_faces)
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def _arena_combined_type_line(
|
|
1242
|
+
*,
|
|
1243
|
+
card: Mapping[str, Any],
|
|
1244
|
+
linked_faces: tuple[Mapping[str, Any], ...],
|
|
1245
|
+
localization: Mapping[int, str],
|
|
1246
|
+
grp_id: int,
|
|
1247
|
+
) -> str:
|
|
1248
|
+
type_lines = [
|
|
1249
|
+
_arena_type_line(
|
|
1250
|
+
card=face,
|
|
1251
|
+
localization=localization,
|
|
1252
|
+
grp_id=grp_id,
|
|
1253
|
+
)
|
|
1254
|
+
for face in (card, *linked_faces)
|
|
1255
|
+
]
|
|
1256
|
+
unique_type_lines = tuple(dict.fromkeys(type_lines))
|
|
1257
|
+
return " // ".join(unique_type_lines)
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def _arena_type_line(
|
|
1261
|
+
*,
|
|
1262
|
+
card: Mapping[str, Any],
|
|
1263
|
+
localization: Mapping[int, str],
|
|
1264
|
+
grp_id: int,
|
|
1265
|
+
) -> str:
|
|
1266
|
+
card_type = _arena_optional_localized_text(
|
|
1267
|
+
localization=localization,
|
|
1268
|
+
text_id=card.get("cardTypeTextId"),
|
|
1269
|
+
field_name=f"Arena card {grp_id}.cardTypeTextId",
|
|
1270
|
+
)
|
|
1271
|
+
subtype = _arena_optional_localized_text(
|
|
1272
|
+
localization=localization,
|
|
1273
|
+
text_id=card.get("subtypeTextId"),
|
|
1274
|
+
field_name=f"Arena card {grp_id}.subtypeTextId",
|
|
1275
|
+
)
|
|
1276
|
+
if card_type is None:
|
|
1277
|
+
raise CardDatabaseError(f"Arena card {grp_id} is missing cardTypeTextId.")
|
|
1278
|
+
|
|
1279
|
+
if subtype is None:
|
|
1280
|
+
return card_type
|
|
1281
|
+
|
|
1282
|
+
return f"{card_type} — {subtype}"
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
def _arena_card_colors(
|
|
1286
|
+
*,
|
|
1287
|
+
card: Mapping[str, Any],
|
|
1288
|
+
linked_faces: tuple[Mapping[str, Any], ...],
|
|
1289
|
+
grp_id: int,
|
|
1290
|
+
) -> tuple[str, ...]:
|
|
1291
|
+
colors: list[str] = []
|
|
1292
|
+
for face in (card, *linked_faces):
|
|
1293
|
+
colors.extend(
|
|
1294
|
+
_arena_color_tuple(
|
|
1295
|
+
face.get("colors", ()),
|
|
1296
|
+
field_name=f"Arena card {grp_id}.colors",
|
|
1297
|
+
)
|
|
1298
|
+
)
|
|
1299
|
+
|
|
1300
|
+
return _ordered_unique_colors(colors=colors)
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def _arena_card_rarity(*, card: Mapping[str, Any], grp_id: int) -> str:
|
|
1304
|
+
rarity_value = card.get("rarity")
|
|
1305
|
+
if isinstance(rarity_value, str):
|
|
1306
|
+
rarity = rarity_value.strip().lower().replace("mythic rare", "mythic")
|
|
1307
|
+
return _required_str(rarity, field_name=f"Arena card {grp_id}.rarity")
|
|
1308
|
+
|
|
1309
|
+
rarity_id = _required_int(rarity_value, field_name=f"Arena card {grp_id}.rarity")
|
|
1310
|
+
try:
|
|
1311
|
+
return ARENA_RARITY_ID_MAP[rarity_id]
|
|
1312
|
+
except KeyError as error:
|
|
1313
|
+
raise CardDatabaseError(
|
|
1314
|
+
f"Invalid Arena rarity id in card {grp_id}.rarity: {rarity_id}."
|
|
1315
|
+
) from error
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
def _arena_card_mana_cost(
|
|
1319
|
+
*,
|
|
1320
|
+
card: Mapping[str, Any],
|
|
1321
|
+
linked_faces: tuple[Mapping[str, Any], ...],
|
|
1322
|
+
) -> str | None:
|
|
1323
|
+
costs = tuple(
|
|
1324
|
+
cost
|
|
1325
|
+
for cost in (
|
|
1326
|
+
_arena_mana_cost(face.get("castingcost", face.get("castingCost")))
|
|
1327
|
+
for face in (card, *linked_faces)
|
|
1328
|
+
)
|
|
1329
|
+
if cost is not None
|
|
1330
|
+
)
|
|
1331
|
+
if not costs:
|
|
1332
|
+
return None
|
|
1333
|
+
|
|
1334
|
+
return " // ".join(costs)
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def _arena_card_produced_mana(
|
|
1338
|
+
*,
|
|
1339
|
+
card: Mapping[str, Any],
|
|
1340
|
+
primary_type_line: str,
|
|
1341
|
+
grp_id: int,
|
|
1342
|
+
) -> tuple[str, ...]:
|
|
1343
|
+
for field_name in (
|
|
1344
|
+
"produced_mana",
|
|
1345
|
+
"producedMana",
|
|
1346
|
+
"producesMana",
|
|
1347
|
+
"manaProduced",
|
|
1348
|
+
):
|
|
1349
|
+
produced_value = card.get(field_name)
|
|
1350
|
+
if produced_value is not None:
|
|
1351
|
+
return _arena_mana_symbol_tuple(
|
|
1352
|
+
produced_value,
|
|
1353
|
+
field_name=f"Arena card {grp_id}.{field_name}",
|
|
1354
|
+
)
|
|
1355
|
+
|
|
1356
|
+
if "Land" not in primary_type_line:
|
|
1357
|
+
return ()
|
|
1358
|
+
|
|
1359
|
+
return _arena_color_tuple(
|
|
1360
|
+
card.get("colorIdentity", ()),
|
|
1361
|
+
field_name=f"Arena card {grp_id}.colorIdentity",
|
|
1362
|
+
)
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
def _arena_mana_cost(value: Any) -> str | None:
|
|
1366
|
+
if not isinstance(value, str) or value in {"", "o0"}:
|
|
1367
|
+
return None
|
|
1368
|
+
|
|
1369
|
+
parts = tuple(part for part in value.split("o") if part and part != "0")
|
|
1370
|
+
if not parts:
|
|
1371
|
+
return None
|
|
1372
|
+
|
|
1373
|
+
return "".join(f"{{{part}}}" for part in parts)
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def _arena_localization_map(
|
|
1377
|
+
*,
|
|
1378
|
+
items: Iterable[Mapping[str, Any]],
|
|
1379
|
+
source: str,
|
|
1380
|
+
) -> dict[int, str]:
|
|
1381
|
+
language = _select_arena_english_localization(items=items, source=source)
|
|
1382
|
+
keys_value = language.get("keys")
|
|
1383
|
+
if not isinstance(keys_value, list):
|
|
1384
|
+
raise CardDatabaseError(
|
|
1385
|
+
f"Malformed Arena localization {source}: selected language has no keys list."
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
localization: dict[int, str] = {}
|
|
1389
|
+
for item in keys_value:
|
|
1390
|
+
if not isinstance(item, dict):
|
|
1391
|
+
raise CardDatabaseError(
|
|
1392
|
+
f"Malformed Arena localization {source}: key entry is not object."
|
|
1393
|
+
)
|
|
1394
|
+
|
|
1395
|
+
text_id = _required_int(item.get("id"), field_name="Arena localization id")
|
|
1396
|
+
text = item.get("text")
|
|
1397
|
+
if not isinstance(text, str):
|
|
1398
|
+
raise CardDatabaseError("Missing or invalid Arena localization text.")
|
|
1399
|
+
|
|
1400
|
+
localization[text_id] = text
|
|
1401
|
+
|
|
1402
|
+
return localization
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def _select_arena_english_localization(
|
|
1406
|
+
*,
|
|
1407
|
+
items: Iterable[Mapping[str, Any]],
|
|
1408
|
+
source: str,
|
|
1409
|
+
) -> Mapping[str, Any]:
|
|
1410
|
+
languages = tuple(items)
|
|
1411
|
+
if not languages:
|
|
1412
|
+
raise CardDatabaseError(f"Malformed Arena localization {source}: empty list.")
|
|
1413
|
+
|
|
1414
|
+
for language in languages:
|
|
1415
|
+
langkey = str(language.get("langkey", "")).lower()
|
|
1416
|
+
iso_code = str(language.get("isoCode", "")).lower()
|
|
1417
|
+
if langkey in {"en", "english"} or iso_code in {"en", "en-us"}:
|
|
1418
|
+
return language
|
|
1419
|
+
|
|
1420
|
+
return languages[0]
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def _arena_localized_text(
|
|
1424
|
+
*,
|
|
1425
|
+
localization: Mapping[int, str],
|
|
1426
|
+
text_id: Any,
|
|
1427
|
+
field_name: str,
|
|
1428
|
+
) -> str:
|
|
1429
|
+
text = _arena_optional_localized_text(
|
|
1430
|
+
localization=localization,
|
|
1431
|
+
text_id=text_id,
|
|
1432
|
+
field_name=field_name,
|
|
1433
|
+
)
|
|
1434
|
+
if text is None:
|
|
1435
|
+
raise CardDatabaseError(f"Missing localization for {field_name}.")
|
|
1436
|
+
|
|
1437
|
+
return text
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def _arena_optional_localized_text(
|
|
1441
|
+
*,
|
|
1442
|
+
localization: Mapping[int, str],
|
|
1443
|
+
text_id: Any,
|
|
1444
|
+
field_name: str,
|
|
1445
|
+
) -> str | None:
|
|
1446
|
+
if text_id is None:
|
|
1447
|
+
return None
|
|
1448
|
+
|
|
1449
|
+
resolved_text_id = _required_int(text_id, field_name=field_name)
|
|
1450
|
+
if resolved_text_id == 0:
|
|
1451
|
+
return None
|
|
1452
|
+
|
|
1453
|
+
text = localization.get(resolved_text_id)
|
|
1454
|
+
if text is None:
|
|
1455
|
+
raise CardDatabaseError(f"Missing localization for {field_name}.")
|
|
1456
|
+
|
|
1457
|
+
if text == "":
|
|
1458
|
+
return None
|
|
1459
|
+
|
|
1460
|
+
return text
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def _arena_data_file_pair(
|
|
1464
|
+
*,
|
|
1465
|
+
path: Path,
|
|
1466
|
+
required: bool,
|
|
1467
|
+
) -> tuple[Path, Path] | None:
|
|
1468
|
+
cards_path = _latest_arena_data_file(path=path, prefix=ARENA_DATA_CARDS_PREFIX)
|
|
1469
|
+
loc_path = _latest_arena_data_file(path=path, prefix=ARENA_DATA_LOC_PREFIX)
|
|
1470
|
+
if cards_path is not None and loc_path is not None:
|
|
1471
|
+
return cards_path, loc_path
|
|
1472
|
+
|
|
1473
|
+
if required:
|
|
1474
|
+
raise CardDatabaseError(
|
|
1475
|
+
f"Arena local data at {path} is missing data_cards*.mtga or data_loc*.mtga."
|
|
1476
|
+
)
|
|
1477
|
+
|
|
1478
|
+
return None
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
def _latest_arena_data_file(*, path: Path, prefix: str) -> Path | None:
|
|
1482
|
+
if not path.is_dir():
|
|
1483
|
+
return None
|
|
1484
|
+
|
|
1485
|
+
candidates = tuple(
|
|
1486
|
+
candidate
|
|
1487
|
+
for candidate in path.iterdir()
|
|
1488
|
+
if candidate.is_file()
|
|
1489
|
+
and candidate.name.startswith(prefix)
|
|
1490
|
+
and candidate.suffix.lower() in ARENA_DATA_FILE_SUFFIXES
|
|
1491
|
+
)
|
|
1492
|
+
if not candidates:
|
|
1493
|
+
return None
|
|
1494
|
+
|
|
1495
|
+
return max(candidates, key=_arena_data_file_sort_key)
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def _arena_data_file_sort_key(path: Path) -> tuple[float, str]:
|
|
1499
|
+
try:
|
|
1500
|
+
modified_at = path.stat().st_mtime
|
|
1501
|
+
except OSError:
|
|
1502
|
+
modified_at = 0.0
|
|
1503
|
+
|
|
1504
|
+
return modified_at, path.name
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
def _load_arena_json_array(*, path: Path, label: str) -> tuple[Mapping[str, Any], ...]:
|
|
1508
|
+
try:
|
|
1509
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
1510
|
+
except OSError as error:
|
|
1511
|
+
raise CardDatabaseError(f"Could not read Arena {label} file {path}: {error}.") from error
|
|
1512
|
+
|
|
1513
|
+
payload = _strip_javascript_assignment(text=text)
|
|
1514
|
+
try:
|
|
1515
|
+
value = json.loads(payload)
|
|
1516
|
+
except json.JSONDecodeError as error:
|
|
1517
|
+
raise CardDatabaseError(
|
|
1518
|
+
f"Malformed Arena {label} JSON at {path}: {error.msg}."
|
|
1519
|
+
) from error
|
|
1520
|
+
|
|
1521
|
+
if not isinstance(value, list):
|
|
1522
|
+
raise CardDatabaseError(f"Malformed Arena {label} JSON at {path}: expected list.")
|
|
1523
|
+
|
|
1524
|
+
objects: list[Mapping[str, Any]] = []
|
|
1525
|
+
for index, item in enumerate(value, start=1):
|
|
1526
|
+
if not isinstance(item, dict):
|
|
1527
|
+
raise CardDatabaseError(
|
|
1528
|
+
f"Malformed Arena {label} JSON at {path}:{index}: expected object."
|
|
1529
|
+
)
|
|
1530
|
+
|
|
1531
|
+
objects.append(item)
|
|
1532
|
+
|
|
1533
|
+
return tuple(objects)
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
def _strip_javascript_assignment(*, text: str) -> str:
|
|
1537
|
+
stripped = text.strip()
|
|
1538
|
+
if stripped.startswith("var ") and "=" in stripped:
|
|
1539
|
+
stripped = stripped.split("=", 1)[1].strip()
|
|
1540
|
+
|
|
1541
|
+
return stripped.rstrip(";").strip()
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
def _default_arena_data_dir_candidates() -> tuple[Path, ...]:
|
|
1545
|
+
current_system = platform.system()
|
|
1546
|
+
home = Path.home()
|
|
1547
|
+
if current_system == "Darwin":
|
|
1548
|
+
return (
|
|
1549
|
+
home
|
|
1550
|
+
/ "Library"
|
|
1551
|
+
/ "Application Support"
|
|
1552
|
+
/ "com.wizards.mtga"
|
|
1553
|
+
/ "Downloads"
|
|
1554
|
+
/ "Data",
|
|
1555
|
+
)
|
|
1556
|
+
|
|
1557
|
+
if current_system == "Windows":
|
|
1558
|
+
candidates: list[Path] = []
|
|
1559
|
+
registry_path = _windows_registry_arena_data_dir()
|
|
1560
|
+
if registry_path is not None:
|
|
1561
|
+
candidates.append(registry_path)
|
|
1562
|
+
|
|
1563
|
+
for root_name in ("ProgramFiles", "ProgramFiles(x86)"):
|
|
1564
|
+
root = os.environ.get(root_name)
|
|
1565
|
+
if root is None:
|
|
1566
|
+
continue
|
|
1567
|
+
|
|
1568
|
+
candidates.append(
|
|
1569
|
+
Path(root)
|
|
1570
|
+
/ "Wizards of the Coast"
|
|
1571
|
+
/ "MTGA"
|
|
1572
|
+
/ "MTGA_Data"
|
|
1573
|
+
/ "Downloads"
|
|
1574
|
+
/ "Data"
|
|
1575
|
+
)
|
|
1576
|
+
|
|
1577
|
+
return tuple(candidates)
|
|
1578
|
+
|
|
1579
|
+
return ()
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
def _windows_registry_arena_data_dir() -> Path | None:
|
|
1583
|
+
if platform.system() != "Windows":
|
|
1584
|
+
return None
|
|
1585
|
+
|
|
1586
|
+
try:
|
|
1587
|
+
import winreg
|
|
1588
|
+
except ImportError:
|
|
1589
|
+
return None
|
|
1590
|
+
|
|
1591
|
+
try:
|
|
1592
|
+
with winreg.OpenKey(
|
|
1593
|
+
winreg.HKEY_LOCAL_MACHINE,
|
|
1594
|
+
r"SOFTWARE\Wizards of the Coast\MTGArena",
|
|
1595
|
+
) as registry_key:
|
|
1596
|
+
install_path, _ = winreg.QueryValueEx(registry_key, "Path")
|
|
1597
|
+
except OSError:
|
|
1598
|
+
return None
|
|
1599
|
+
|
|
1600
|
+
if not isinstance(install_path, str):
|
|
1601
|
+
return None
|
|
1602
|
+
|
|
1603
|
+
return Path(install_path) / "MTGA_Data" / "Downloads" / "Data"
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
def _open_text_bulk_file(*, path: Path) -> io.TextIOBase:
|
|
1607
|
+
if path.suffix == ".gz":
|
|
1608
|
+
return gzip.open(path, mode="rt", encoding="utf-8")
|
|
1609
|
+
|
|
1610
|
+
return path.open(mode="rt", encoding="utf-8")
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
def _iter_scryfall_jsonl_url(
|
|
1614
|
+
*,
|
|
1615
|
+
url: str,
|
|
1616
|
+
timeout_seconds: int,
|
|
1617
|
+
) -> Iterator[Mapping[str, Any]]:
|
|
1618
|
+
request = _request(url=url)
|
|
1619
|
+
try:
|
|
1620
|
+
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
|
|
1621
|
+
with gzip.GzipFile(fileobj=response) as compressed:
|
|
1622
|
+
text_stream = io.TextIOWrapper(compressed, encoding="utf-8")
|
|
1623
|
+
yield from _iter_jsonl_objects(lines=text_stream, source=url)
|
|
1624
|
+
except urllib.error.URLError as error:
|
|
1625
|
+
raise CardDatabaseError(f"Failed to download Scryfall bulk data: {error}") from error
|
|
1626
|
+
except OSError as error:
|
|
1627
|
+
raise CardDatabaseError(f"Failed to decompress Scryfall bulk data: {error}") from error
|
|
1628
|
+
|
|
1629
|
+
|
|
1630
|
+
def _iter_jsonl_objects(
|
|
1631
|
+
*,
|
|
1632
|
+
lines: Iterable[str],
|
|
1633
|
+
source: str,
|
|
1634
|
+
) -> Iterator[Mapping[str, Any]]:
|
|
1635
|
+
for line_number, line in enumerate(lines, start=1):
|
|
1636
|
+
stripped = line.strip()
|
|
1637
|
+
if not stripped:
|
|
1638
|
+
continue
|
|
1639
|
+
|
|
1640
|
+
try:
|
|
1641
|
+
item = json.loads(stripped)
|
|
1642
|
+
except json.JSONDecodeError as error:
|
|
1643
|
+
raise CardDatabaseError(
|
|
1644
|
+
f"Malformed Scryfall bulk JSON at {source}:{line_number}: {error.msg}."
|
|
1645
|
+
) from error
|
|
1646
|
+
|
|
1647
|
+
if not isinstance(item, dict):
|
|
1648
|
+
raise CardDatabaseError(
|
|
1649
|
+
f"Malformed Scryfall bulk JSON at {source}:{line_number}: "
|
|
1650
|
+
"expected object."
|
|
1651
|
+
)
|
|
1652
|
+
|
|
1653
|
+
yield item
|
|
1654
|
+
|
|
1655
|
+
|
|
1656
|
+
def _fetch_bulk_data_items(*, timeout_seconds: int) -> list[Mapping[str, Any]]:
|
|
1657
|
+
request = _request(url=SCRYFALL_BULK_DATA_URL)
|
|
1658
|
+
try:
|
|
1659
|
+
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
|
|
1660
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
1661
|
+
except urllib.error.URLError as error:
|
|
1662
|
+
raise CardDatabaseError(f"Failed to query Scryfall bulk metadata: {error}") from error
|
|
1663
|
+
except json.JSONDecodeError as error:
|
|
1664
|
+
raise CardDatabaseError(f"Malformed Scryfall bulk metadata: {error}") from error
|
|
1665
|
+
|
|
1666
|
+
if not isinstance(payload, dict):
|
|
1667
|
+
raise CardDatabaseError("Malformed Scryfall bulk metadata: expected object.")
|
|
1668
|
+
|
|
1669
|
+
data = payload.get("data")
|
|
1670
|
+
if not isinstance(data, list):
|
|
1671
|
+
raise CardDatabaseError("Malformed Scryfall bulk metadata: missing data list.")
|
|
1672
|
+
|
|
1673
|
+
items: list[Mapping[str, Any]] = []
|
|
1674
|
+
for item in data:
|
|
1675
|
+
if not isinstance(item, dict):
|
|
1676
|
+
raise CardDatabaseError("Malformed Scryfall bulk metadata: item is not object.")
|
|
1677
|
+
|
|
1678
|
+
items.append(item)
|
|
1679
|
+
|
|
1680
|
+
return items
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
def _default_cards_download_uri(*, bulk_items: Iterable[Mapping[str, Any]]) -> str:
|
|
1684
|
+
for item in bulk_items:
|
|
1685
|
+
if item.get("type") != SCRYFALL_DEFAULT_CARDS_TYPE:
|
|
1686
|
+
continue
|
|
1687
|
+
|
|
1688
|
+
uri = item.get("jsonl_download_uri", item.get("download_uri"))
|
|
1689
|
+
return _required_str(uri, field_name="default_cards.download_uri")
|
|
1690
|
+
|
|
1691
|
+
raise CardDatabaseError("Scryfall bulk metadata did not include default_cards.")
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
def _request(*, url: str) -> urllib.request.Request:
|
|
1695
|
+
return urllib.request.Request(
|
|
1696
|
+
url,
|
|
1697
|
+
headers={
|
|
1698
|
+
"Accept": "application/json;q=0.9,*/*;q=0.8",
|
|
1699
|
+
"User-Agent": SCRYFALL_USER_AGENT,
|
|
1700
|
+
},
|
|
1701
|
+
)
|
|
1702
|
+
|
|
1703
|
+
|
|
1704
|
+
def _color_tuple(value: Any, *, field_name: str) -> tuple[str, ...]:
|
|
1705
|
+
colors = _string_tuple(value, field_name=field_name)
|
|
1706
|
+
invalid = [color for color in colors if color not in COLOR_ORDER]
|
|
1707
|
+
if invalid:
|
|
1708
|
+
raise CardDatabaseError(f"Invalid color values in {field_name}: {invalid}.")
|
|
1709
|
+
|
|
1710
|
+
return _ordered_unique_colors(colors=colors)
|
|
1711
|
+
|
|
1712
|
+
|
|
1713
|
+
def _arena_color_tuple(value: Any, *, field_name: str) -> tuple[str, ...]:
|
|
1714
|
+
if isinstance(value, (str, bytes)) or not isinstance(value, Iterable):
|
|
1715
|
+
raise CardDatabaseError(f"Missing or invalid {field_name}; expected id list.")
|
|
1716
|
+
|
|
1717
|
+
colors: list[str] = []
|
|
1718
|
+
for item in value:
|
|
1719
|
+
color_id = _required_int(item, field_name=field_name)
|
|
1720
|
+
try:
|
|
1721
|
+
colors.append(ARENA_COLOR_ID_MAP[color_id])
|
|
1722
|
+
except KeyError as error:
|
|
1723
|
+
raise CardDatabaseError(
|
|
1724
|
+
f"Invalid Arena color id in {field_name}: {color_id}."
|
|
1725
|
+
) from error
|
|
1726
|
+
|
|
1727
|
+
return _ordered_unique_colors(colors=colors)
|
|
1728
|
+
|
|
1729
|
+
|
|
1730
|
+
def _arena_mana_symbol_tuple(value: Any, *, field_name: str) -> tuple[str, ...]:
|
|
1731
|
+
if isinstance(value, (str, bytes)) or not isinstance(value, Iterable):
|
|
1732
|
+
raise CardDatabaseError(f"Missing or invalid {field_name}; expected mana list.")
|
|
1733
|
+
|
|
1734
|
+
colors: list[str] = []
|
|
1735
|
+
for item in value:
|
|
1736
|
+
if isinstance(item, str):
|
|
1737
|
+
if item == "C":
|
|
1738
|
+
continue
|
|
1739
|
+
if item not in COLOR_ORDER:
|
|
1740
|
+
raise CardDatabaseError(
|
|
1741
|
+
f"Invalid Arena mana value in {field_name}: {item}."
|
|
1742
|
+
)
|
|
1743
|
+
|
|
1744
|
+
colors.append(item)
|
|
1745
|
+
continue
|
|
1746
|
+
|
|
1747
|
+
color_id = _required_int(item, field_name=field_name)
|
|
1748
|
+
try:
|
|
1749
|
+
colors.append(ARENA_COLOR_ID_MAP[color_id])
|
|
1750
|
+
except KeyError as error:
|
|
1751
|
+
raise CardDatabaseError(
|
|
1752
|
+
f"Invalid Arena mana id in {field_name}: {color_id}."
|
|
1753
|
+
) from error
|
|
1754
|
+
|
|
1755
|
+
return _ordered_unique_colors(colors=colors)
|
|
1756
|
+
|
|
1757
|
+
|
|
1758
|
+
def _produced_mana_tuple(value: Any, *, field_name: str) -> tuple[str, ...]:
|
|
1759
|
+
mana_symbols = _string_tuple(value, field_name=field_name)
|
|
1760
|
+
invalid = [symbol for symbol in mana_symbols if symbol not in (*COLOR_ORDER, "C")]
|
|
1761
|
+
if invalid:
|
|
1762
|
+
raise CardDatabaseError(f"Invalid mana values in {field_name}: {invalid}.")
|
|
1763
|
+
|
|
1764
|
+
return _ordered_unique_colors(
|
|
1765
|
+
colors=(symbol for symbol in mana_symbols if symbol in COLOR_ORDER)
|
|
1766
|
+
)
|
|
1767
|
+
|
|
1768
|
+
|
|
1769
|
+
def _ordered_unique_colors(*, colors: Iterable[str]) -> tuple[str, ...]:
|
|
1770
|
+
color_set = set(colors)
|
|
1771
|
+
return tuple(color for color in COLOR_ORDER if color in color_set)
|
|
1772
|
+
|
|
1773
|
+
|
|
1774
|
+
def _string_tuple(value: Any, *, field_name: str) -> tuple[str, ...]:
|
|
1775
|
+
if isinstance(value, (str, bytes)) or not isinstance(value, Iterable):
|
|
1776
|
+
raise CardDatabaseError(f"Missing or invalid {field_name}; expected string list.")
|
|
1777
|
+
|
|
1778
|
+
result: list[str] = []
|
|
1779
|
+
for item in value:
|
|
1780
|
+
if not isinstance(item, str):
|
|
1781
|
+
raise CardDatabaseError(
|
|
1782
|
+
f"Missing or invalid {field_name}; expected only strings."
|
|
1783
|
+
)
|
|
1784
|
+
|
|
1785
|
+
result.append(item)
|
|
1786
|
+
|
|
1787
|
+
return tuple(result)
|
|
1788
|
+
|
|
1789
|
+
|
|
1790
|
+
def _required_str(value: Any, *, field_name: str) -> str:
|
|
1791
|
+
if not isinstance(value, str) or value == "":
|
|
1792
|
+
raise CardDatabaseError(
|
|
1793
|
+
f"Missing or invalid {field_name}; expected non-empty string."
|
|
1794
|
+
)
|
|
1795
|
+
|
|
1796
|
+
return value
|
|
1797
|
+
|
|
1798
|
+
|
|
1799
|
+
def _optional_str(value: Any, *, field_name: str) -> str | None:
|
|
1800
|
+
if value is None:
|
|
1801
|
+
return None
|
|
1802
|
+
|
|
1803
|
+
return _required_str(value, field_name=field_name)
|
|
1804
|
+
|
|
1805
|
+
|
|
1806
|
+
def _optional_datetime(value: Any) -> datetime | None:
|
|
1807
|
+
"""Parse a timezone-aware ISO-8601 timestamp when available.
|
|
1808
|
+
Legacy or malformed values are treated as unknown metadata.
|
|
1809
|
+
"""
|
|
1810
|
+
|
|
1811
|
+
if not isinstance(value, str) or value == "":
|
|
1812
|
+
return None
|
|
1813
|
+
|
|
1814
|
+
try:
|
|
1815
|
+
parsed = datetime.fromisoformat(value)
|
|
1816
|
+
except ValueError:
|
|
1817
|
+
return None
|
|
1818
|
+
|
|
1819
|
+
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
|
1820
|
+
return None
|
|
1821
|
+
|
|
1822
|
+
return parsed.astimezone(UTC)
|
|
1823
|
+
|
|
1824
|
+
|
|
1825
|
+
def _utc_isoformat(*, value: datetime | None) -> str | None:
|
|
1826
|
+
"""Serialize an aware timestamp as canonical UTC ISO-8601 text.
|
|
1827
|
+
Naive values cannot identify a successful UTC refresh.
|
|
1828
|
+
"""
|
|
1829
|
+
|
|
1830
|
+
if (
|
|
1831
|
+
not isinstance(value, datetime)
|
|
1832
|
+
or value.tzinfo is None
|
|
1833
|
+
or value.utcoffset() is None
|
|
1834
|
+
):
|
|
1835
|
+
return None
|
|
1836
|
+
|
|
1837
|
+
return value.astimezone(UTC).isoformat()
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
def _required_int(value: Any, *, field_name: str) -> int:
|
|
1841
|
+
if isinstance(value, bool):
|
|
1842
|
+
raise CardDatabaseError(f"Missing or invalid {field_name}; expected integer.")
|
|
1843
|
+
|
|
1844
|
+
try:
|
|
1845
|
+
return int(value)
|
|
1846
|
+
except (TypeError, ValueError) as error:
|
|
1847
|
+
raise CardDatabaseError(
|
|
1848
|
+
f"Missing or invalid {field_name}; expected integer."
|
|
1849
|
+
) from error
|
|
1850
|
+
|
|
1851
|
+
|
|
1852
|
+
def _required_float(value: Any, *, field_name: str) -> float:
|
|
1853
|
+
if isinstance(value, bool):
|
|
1854
|
+
raise CardDatabaseError(f"Missing or invalid {field_name}; expected number.")
|
|
1855
|
+
|
|
1856
|
+
try:
|
|
1857
|
+
return float(value)
|
|
1858
|
+
except (TypeError, ValueError) as error:
|
|
1859
|
+
raise CardDatabaseError(
|
|
1860
|
+
f"Missing or invalid {field_name}; expected number."
|
|
1861
|
+
) from error
|
|
1862
|
+
|
|
1863
|
+
|
|
1864
|
+
def _optional_float(value: Any, *, field_name: str) -> float | None:
|
|
1865
|
+
if value is None:
|
|
1866
|
+
return None
|
|
1867
|
+
|
|
1868
|
+
return _required_float(value, field_name=field_name)
|
|
1869
|
+
|