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
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
+