from __future__ import annotations from typing import Any import httpx class SCMDBError(RuntimeError): pass class SCMDBClient: def __init__(self, base_url: str = "https://scmdb.net") -> None: self.base_url = base_url.rstrip("/") self._versions: list[dict[str, Any]] | None = None self._data_cache: dict[str, dict[str, Any]] = {} async def list_versions(self) -> list[dict[str, Any]]: if self._versions is not None: return self._versions body = await self._get_json("data/versions.json") if not isinstance(body, list): raise SCMDBError("SCMDB versions response was not a list.") self._versions = [ item for item in body if isinstance(item, dict) and item.get("version") and item.get("file") ] return self._versions async def get_data(self, version: str | None = None, channel: str = "live") -> dict[str, Any]: selected = await self.resolve_version(version=version, channel=channel) cache_key = str(selected["version"]) if cache_key not in self._data_cache: body = await self._get_json(f"data/{selected['file']}") if not isinstance(body, dict): raise SCMDBError(f"SCMDB data for {cache_key} was not an object.") self._data_cache[cache_key] = body return self._data_cache[cache_key] async def resolve_version(self, version: str | None = None, channel: str = "live") -> dict[str, Any]: versions = await self.list_versions() if not versions: raise SCMDBError("SCMDB did not return any data versions.") if version: needle = version.casefold().strip() for item in versions: item_version = str(item["version"]) if item_version.casefold() == needle or needle in item_version.casefold(): return item raise SCMDBError(f"SCMDB version not found: {version}") channel = (channel or "live").casefold().strip() if channel in {"latest", "any", "all"}: return versions[0] if channel not in {"live", "ptu"}: raise SCMDBError("SCMDB channel must be live, ptu, or latest.") for item in versions: if f"-{channel}." in str(item["version"]).casefold(): return item return versions[0] async def _get_json(self, path: str) -> Any: async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: response = await client.get(f"{self.base_url}/{path.lstrip('/')}", headers={"Accept": "application/json"}) try: body = response.json() except ValueError as exc: raise SCMDBError(f"SCMDB returned non-JSON response: HTTP {response.status_code}") from exc if response.status_code >= 400: raise SCMDBError(f"SCMDB HTTP {response.status_code}: {body}") return body