Restructure into full project layout
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Scryfall API client with Redis caching.
|
||||
|
||||
Respects Scryfall's rate limit (10 req/s with a small safety margin).
|
||||
Cards are cached for 24 hours by default (SCRYFALL_CACHE_TTL_SECONDS).
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.redis import get_redis
|
||||
|
||||
SCRYFALL_BASE = "https://api.scryfall.com"
|
||||
_semaphore: asyncio.Semaphore | None = None
|
||||
|
||||
|
||||
def _get_semaphore() -> asyncio.Semaphore:
|
||||
global _semaphore
|
||||
if _semaphore is None:
|
||||
_semaphore = asyncio.Semaphore(settings.SCRYFALL_RATE_LIMIT_RPS)
|
||||
return _semaphore
|
||||
|
||||
|
||||
def _cache_key(kind: str, value: str) -> str:
|
||||
return f"scryfall:{kind}:{value.lower()}"
|
||||
|
||||
|
||||
async def _cache_get(key: str) -> Optional[dict]:
|
||||
redis = await get_redis()
|
||||
raw = await redis.get(key)
|
||||
return json.loads(raw) if raw else None
|
||||
|
||||
|
||||
async def _cache_set(key: str, data: dict) -> None:
|
||||
redis = await get_redis()
|
||||
await redis.set(key, json.dumps(data), ex=settings.SCRYFALL_CACHE_TTL_SECONDS)
|
||||
|
||||
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=0.5, min=0.5, max=4))
|
||||
async def _get(path: str, params: dict | None = None) -> dict:
|
||||
async with _get_semaphore():
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{SCRYFALL_BASE}{path}",
|
||||
params=params,
|
||||
headers={"User-Agent": "MTGDeckBuilder/1.0"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def get_card_by_name(name: str, exact: bool = True) -> Optional[dict]:
|
||||
"""Fetch a card by name. Uses fuzzy search if exact=False."""
|
||||
key = _cache_key("name", name)
|
||||
cached = await _cache_get(key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
try:
|
||||
if exact:
|
||||
data = await _get("/cards/named", {"exact": name})
|
||||
else:
|
||||
data = await _get("/cards/named", {"fuzzy": name})
|
||||
await _cache_set(key, data)
|
||||
# Also cache by scryfall id
|
||||
await _cache_set(_cache_key("id", data["id"]), data)
|
||||
return data
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
async def get_card_by_id(scryfall_id: str) -> Optional[dict]:
|
||||
"""Fetch a card by its Scryfall UUID."""
|
||||
key = _cache_key("id", scryfall_id)
|
||||
cached = await _cache_get(key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
try:
|
||||
data = await _get(f"/cards/{scryfall_id}")
|
||||
await _cache_set(key, data)
|
||||
return data
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
async def get_card_by_set_and_collector(set_code: str, collector_number: str) -> Optional[dict]:
|
||||
"""Fetch a card by set + collector number (used during import enrichment)."""
|
||||
key = _cache_key("setcol", f"{set_code}:{collector_number}")
|
||||
cached = await _cache_get(key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
try:
|
||||
data = await _get(f"/cards/{set_code.lower()}/{collector_number}")
|
||||
await _cache_set(key, data)
|
||||
await _cache_set(_cache_key("id", data["id"]), data)
|
||||
return data
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
async def batch_enrich_by_name(names: list[str]) -> dict[str, Optional[dict]]:
|
||||
"""
|
||||
Resolve up to 75 card names at once via Scryfall's /cards/collection endpoint.
|
||||
Returns a dict of {name_lower: scryfall_data}.
|
||||
Falls back to None for cards not found.
|
||||
"""
|
||||
results: dict[str, Optional[dict]] = {}
|
||||
uncached: list[str] = []
|
||||
|
||||
# Check cache first
|
||||
for name in names:
|
||||
key = _cache_key("name", name)
|
||||
cached = await _cache_get(key)
|
||||
if cached:
|
||||
results[name.lower()] = cached
|
||||
else:
|
||||
uncached.append(name)
|
||||
|
||||
# Batch fetch uncached in chunks of 75 (Scryfall limit)
|
||||
for chunk_start in range(0, len(uncached), 75):
|
||||
chunk = uncached[chunk_start : chunk_start + 75]
|
||||
identifiers = [{"name": n} for n in chunk]
|
||||
|
||||
async with _get_semaphore():
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
f"{SCRYFALL_BASE}/cards/collection",
|
||||
json={"identifiers": identifiers},
|
||||
headers={"User-Agent": "MTGDeckBuilder/1.0"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
for card in data.get("data", []):
|
||||
name_lower = card["name"].lower()
|
||||
results[name_lower] = card
|
||||
await _cache_set(_cache_key("name", card["name"]), card)
|
||||
await _cache_set(_cache_key("id", card["id"]), card)
|
||||
|
||||
# Mark not-found as None
|
||||
found_lower = {c["name"].lower() for c in data.get("data", [])}
|
||||
for name in chunk:
|
||||
if name.lower() not in found_lower:
|
||||
results[name.lower()] = None
|
||||
|
||||
# Be polite between chunks
|
||||
if chunk_start + 75 < len(uncached):
|
||||
await asyncio.sleep(0.15)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def extract_price_usd(scryfall_data: dict) -> Optional[float]:
|
||||
"""Pull the cheapest non-foil USD price from a Scryfall card object."""
|
||||
try:
|
||||
price = scryfall_data.get("prices", {}).get("usd")
|
||||
return float(price) if price else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def card_image_url(scryfall_data: dict, size: str = "normal") -> Optional[str]:
|
||||
"""Return an image URL. Handles split/transform/MDFCs gracefully."""
|
||||
images = scryfall_data.get("image_uris")
|
||||
if images:
|
||||
return images.get(size)
|
||||
# Double-faced cards store images per face
|
||||
faces = scryfall_data.get("card_faces", [])
|
||||
if faces:
|
||||
return faces[0].get("image_uris", {}).get(size)
|
||||
return None
|
||||
|
||||
|
||||
def is_commander_legal(scryfall_data: dict) -> bool:
|
||||
return scryfall_data.get("legalities", {}).get("commander") == "legal"
|
||||
Reference in New Issue
Block a user