thesportsdb_client
A Python client for the TheSportsDB API, v1 and v2.
SportsDB (blocking) and AsyncSportsDB (asyncio or trio) share one implementation:
every endpoint, typed models, helpers for common tasks, client-side rate limiting, retries,
optional caching and de-duplication of identical calls in flight. API keys never appear in
errors or cache keys.
1"""A Python client for the TheSportsDB API, v1 and v2. 2 3``SportsDB`` (blocking) and ``AsyncSportsDB`` (asyncio or trio) share one implementation: 4every endpoint, typed models, helpers for common tasks, client-side rate limiting, retries, 5optional caching and de-duplication of identical calls in flight. API keys never appear in 6errors or cache keys. 7""" 8 9from ._async._transport import AsyncTransport, HttpxAsyncTransport, Response 10from ._async.client import AsyncSportsDB 11from ._sync._transport import HttpxTransport, Transport 12from ._sync.client import SportsDB 13from .cache import CachePolicy, Freshness, InMemoryResponseCache, ResponseCache 14from .config import FREE_API_KEY, FREE_API_KEYS, Config 15from .errors import ( 16 ApiMessageError, 17 HTTPStatusError, 18 InvalidApiKeyError, 19 NetworkError, 20 PremiumRequiredError, 21 RateLimitError, 22 ResponseParseError, 23 SportsDBError, 24) 25from .events import RequestEvent 26from .models import ( 27 ApiRecord, 28 Contract, 29 Country, 30 Equipment, 31 Event, 32 EventResult, 33 EventStat, 34 EventStatus, 35 FormerTeam, 36 Honour, 37 ImageSize, 38 League, 39 LeagueRef, 40 LineupEntry, 41 LiveScore, 42 Milestone, 43 Player, 44 PlayerExternalIds, 45 PlayerStat, 46 RoundStage, 47 Season, 48 SeasonPoster, 49 Socials, 50 Sport, 51 Standing, 52 Team, 53 TimelineEntry, 54 TvListing, 55 Venue, 56 sized, 57) 58 59__version__ = "0.1.0" 60 61__all__ = [ 62 "AsyncSportsDB", "SportsDB", "Config", "FREE_API_KEY", "FREE_API_KEYS", 63 "AsyncTransport", "Transport", "HttpxAsyncTransport", "HttpxTransport", "Response", 64 "CachePolicy", "Freshness", "InMemoryResponseCache", "ResponseCache", "RequestEvent", 65 "SportsDBError", "InvalidApiKeyError", "PremiumRequiredError", "RateLimitError", "HTTPStatusError", 66 "ResponseParseError", "ApiMessageError", "NetworkError", 67 "ApiRecord", "Contract", "Country", "Equipment", "Event", "EventResult", "EventStat", "EventStatus", 68 "FormerTeam", "Honour", "ImageSize", "League", "LeagueRef", "LineupEntry", "LiveScore", "Milestone", 69 "Player", "PlayerExternalIds", "PlayerStat", "RoundStage", "Season", "SeasonPoster", "Socials", "Sport", "Standing", "Team", 70 "TimelineEntry", "TvListing", "Venue", "sized", 71]
24class AsyncSportsDB: 25 """The asynchronous TheSportsDB client. 26 27 .. code-block:: python 28 29 async with AsyncSportsDB() as db: # free key 123: v1 only 30 arsenal = await db.v1.lookup.team(133604) 31 async with AsyncSportsDB(api_key=key) as db: # premium: v1 and v2 32 tv = await db.v2.tv.country("Canada") 33 34 One client is safe to share across tasks; its rate limiter, cache and de-duplication cover 35 every call made through it. Create one per key and reuse it. 36 """ 37 38 def __init__( 39 self, 40 api_key: str = FREE_API_KEY, 41 *, 42 transport: AsyncTransport | None = None, 43 requests_per_minute: int | None = None, 44 max_retries: int = 2, 45 retry_backoff: float = 0.5, 46 retry_on_rate_limit: bool = True, 47 rate_limit_wait: float = 60.0, 48 cache: ResponseCache | None = None, 49 cache_policy: CachePolicy | None = None, 50 timeout: float = 30.0, 51 deduplicate_requests: bool = True, 52 request_listener: Callable[[RequestEvent], None] | None = None, 53 base_url: str = "https://www.thesportsdb.com", 54 user_agent: str = "thesportsdb-client-python", 55 ) -> None: 56 options: dict[str, Any] = dict( 57 api_key=api_key, requests_per_minute=requests_per_minute, max_retries=max_retries, 58 retry_backoff=retry_backoff, retry_on_rate_limit=retry_on_rate_limit, rate_limit_wait=rate_limit_wait, 59 cache=cache, timeout=timeout, deduplicate_requests=deduplicate_requests, 60 request_listener=request_listener, base_url=base_url, user_agent=user_agent, 61 ) 62 if cache_policy is not None: 63 options["cache_policy"] = cache_policy 64 self.config = Config(**options) 65 self._owns_transport = transport is None 66 self._transport = transport or HttpxAsyncTransport(timeout=timeout) 67 requester = AsyncRequester(self.config, self._transport) 68 self.v1 = AsyncV1Api(requester) 69 """The v1 API (free and premium keys).""" 70 self.v2 = AsyncV2Api(requester) 71 """The v2 API (premium keys only).""" 72 self.helpers = AsyncHelpers(self) 73 """Common tasks in one call, using v2 or v1 depending on the key.""" 74 75 async def is_premium_key(self) -> bool: 76 """Whether v2 accepts the key. One call (v2 ``lookup/league/4328``) unless it's a free key.""" 77 if self.config.is_free_key: 78 return False 79 try: 80 await self.v2.lookup.league(4328) 81 return True 82 except InvalidApiKeyError: 83 return False 84 85 async def aclose(self) -> None: 86 """Closes the HTTP connections the client opened (not a transport you passed in).""" 87 if self._owns_transport: 88 await self._transport.aclose() 89 90 async def __aenter__(self) -> AsyncSportsDB: 91 return self 92 93 async def __aexit__( 94 self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None 95 ) -> None: 96 await self.aclose()
The asynchronous TheSportsDB client.
async with AsyncSportsDB() as db: # free key 123: v1 only
arsenal = await db.v1.lookup.team(133604)
async with AsyncSportsDB(api_key=key) as db: # premium: v1 and v2
tv = await db.v2.tv.country("Canada")
One client is safe to share across tasks; its rate limiter, cache and de-duplication cover every call made through it. Create one per key and reuse it.
38 def __init__( 39 self, 40 api_key: str = FREE_API_KEY, 41 *, 42 transport: AsyncTransport | None = None, 43 requests_per_minute: int | None = None, 44 max_retries: int = 2, 45 retry_backoff: float = 0.5, 46 retry_on_rate_limit: bool = True, 47 rate_limit_wait: float = 60.0, 48 cache: ResponseCache | None = None, 49 cache_policy: CachePolicy | None = None, 50 timeout: float = 30.0, 51 deduplicate_requests: bool = True, 52 request_listener: Callable[[RequestEvent], None] | None = None, 53 base_url: str = "https://www.thesportsdb.com", 54 user_agent: str = "thesportsdb-client-python", 55 ) -> None: 56 options: dict[str, Any] = dict( 57 api_key=api_key, requests_per_minute=requests_per_minute, max_retries=max_retries, 58 retry_backoff=retry_backoff, retry_on_rate_limit=retry_on_rate_limit, rate_limit_wait=rate_limit_wait, 59 cache=cache, timeout=timeout, deduplicate_requests=deduplicate_requests, 60 request_listener=request_listener, base_url=base_url, user_agent=user_agent, 61 ) 62 if cache_policy is not None: 63 options["cache_policy"] = cache_policy 64 self.config = Config(**options) 65 self._owns_transport = transport is None 66 self._transport = transport or HttpxAsyncTransport(timeout=timeout) 67 requester = AsyncRequester(self.config, self._transport) 68 self.v1 = AsyncV1Api(requester) 69 """The v1 API (free and premium keys).""" 70 self.v2 = AsyncV2Api(requester) 71 """The v2 API (premium keys only).""" 72 self.helpers = AsyncHelpers(self) 73 """Common tasks in one call, using v2 or v1 depending on the key."""
25class SportsDB: 26 """The blocking TheSportsDB client. 27 28 .. code-block:: python 29 30 with SportsDB() as db: # free key 123: v1 only 31 arsenal = db.v1.lookup.team(133604) 32 with SportsDB(api_key=key) as db: # premium: v1 and v2 33 tv = db.v2.tv.country("Canada") 34 35 One client is safe to share across tasks; its rate limiter, cache and de-duplication cover 36 every call made through it. Create one per key and reuse it. 37 """ 38 39 def __init__( 40 self, 41 api_key: str = FREE_API_KEY, 42 *, 43 transport: Transport | None = None, 44 requests_per_minute: int | None = None, 45 max_retries: int = 2, 46 retry_backoff: float = 0.5, 47 retry_on_rate_limit: bool = True, 48 rate_limit_wait: float = 60.0, 49 cache: ResponseCache | None = None, 50 cache_policy: CachePolicy | None = None, 51 timeout: float = 30.0, 52 deduplicate_requests: bool = True, 53 request_listener: Callable[[RequestEvent], None] | None = None, 54 base_url: str = "https://www.thesportsdb.com", 55 user_agent: str = "thesportsdb-client-python", 56 ) -> None: 57 options: dict[str, Any] = dict( 58 api_key=api_key, requests_per_minute=requests_per_minute, max_retries=max_retries, 59 retry_backoff=retry_backoff, retry_on_rate_limit=retry_on_rate_limit, rate_limit_wait=rate_limit_wait, 60 cache=cache, timeout=timeout, deduplicate_requests=deduplicate_requests, 61 request_listener=request_listener, base_url=base_url, user_agent=user_agent, 62 ) 63 if cache_policy is not None: 64 options["cache_policy"] = cache_policy 65 self.config = Config(**options) 66 self._owns_transport = transport is None 67 self._transport = transport or HttpxTransport(timeout=timeout) 68 requester = Requester(self.config, self._transport) 69 self.v1 = V1Api(requester) 70 """The v1 API (free and premium keys).""" 71 self.v2 = V2Api(requester) 72 """The v2 API (premium keys only).""" 73 self.helpers = Helpers(self) 74 """Common tasks in one call, using v2 or v1 depending on the key.""" 75 76 def is_premium_key(self) -> bool: 77 """Whether v2 accepts the key. One call (v2 ``lookup/league/4328``) unless it's a free key.""" 78 if self.config.is_free_key: 79 return False 80 try: 81 self.v2.lookup.league(4328) 82 return True 83 except InvalidApiKeyError: 84 return False 85 86 def close(self) -> None: 87 """Closes the HTTP connections the client opened (not a transport you passed in).""" 88 if self._owns_transport: 89 self._transport.close() 90 91 def __enter__(self) -> SportsDB: 92 return self 93 94 def __exit__( 95 self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None 96 ) -> None: 97 self.close()
The blocking TheSportsDB client.
with SportsDB() as db: # free key 123: v1 only
arsenal = db.v1.lookup.team(133604)
with SportsDB(api_key=key) as db: # premium: v1 and v2
tv = db.v2.tv.country("Canada")
One client is safe to share across tasks; its rate limiter, cache and de-duplication cover every call made through it. Create one per key and reuse it.
39 def __init__( 40 self, 41 api_key: str = FREE_API_KEY, 42 *, 43 transport: Transport | None = None, 44 requests_per_minute: int | None = None, 45 max_retries: int = 2, 46 retry_backoff: float = 0.5, 47 retry_on_rate_limit: bool = True, 48 rate_limit_wait: float = 60.0, 49 cache: ResponseCache | None = None, 50 cache_policy: CachePolicy | None = None, 51 timeout: float = 30.0, 52 deduplicate_requests: bool = True, 53 request_listener: Callable[[RequestEvent], None] | None = None, 54 base_url: str = "https://www.thesportsdb.com", 55 user_agent: str = "thesportsdb-client-python", 56 ) -> None: 57 options: dict[str, Any] = dict( 58 api_key=api_key, requests_per_minute=requests_per_minute, max_retries=max_retries, 59 retry_backoff=retry_backoff, retry_on_rate_limit=retry_on_rate_limit, rate_limit_wait=rate_limit_wait, 60 cache=cache, timeout=timeout, deduplicate_requests=deduplicate_requests, 61 request_listener=request_listener, base_url=base_url, user_agent=user_agent, 62 ) 63 if cache_policy is not None: 64 options["cache_policy"] = cache_policy 65 self.config = Config(**options) 66 self._owns_transport = transport is None 67 self._transport = transport or HttpxTransport(timeout=timeout) 68 requester = Requester(self.config, self._transport) 69 self.v1 = V1Api(requester) 70 """The v1 API (free and premium keys).""" 71 self.v2 = V2Api(requester) 72 """The v2 API (premium keys only).""" 73 self.helpers = Helpers(self) 74 """Common tasks in one call, using v2 or v1 depending on the key."""
19@dataclass(frozen=True, kw_only=True) 20class Config: 21 api_key: str = FREE_API_KEY 22 requests_per_minute: int | None = None 23 """Client-side limit shared by v1 and v2. None: 30 for a free key, 100 otherwise. 0 turns it off.""" 24 max_retries: int = 2 25 """Retries for network errors and HTTP 5xx, with exponential backoff.""" 26 retry_backoff: float = 0.5 27 """First backoff, in seconds; doubles on every retry.""" 28 retry_on_rate_limit: bool = True 29 """On HTTP 429, wait (Retry-After, or rate_limit_wait) and retry once.""" 30 rate_limit_wait: float = 60.0 31 cache: ResponseCache | None = None 32 cache_policy: CachePolicy = field(default_factory=CachePolicy) 33 timeout: float = 30.0 34 """Seconds before a request gives up (connect and read).""" 35 deduplicate_requests: bool = True 36 """Identical calls made at the same time share one HTTP request.""" 37 request_listener: Callable[[RequestEvent], None] | None = None 38 """Called once per call, after it finishes. Exceptions it raises are ignored.""" 39 base_url: str = "https://www.thesportsdb.com" 40 user_agent: str = "thesportsdb-client-python" 41 42 @property 43 def is_free_key(self) -> bool: 44 return self.api_key in FREE_API_KEYS 45 46 @property 47 def effective_requests_per_minute(self) -> int: 48 if self.requests_per_minute is not None: 49 return self.requests_per_minute 50 return 30 if self.is_free_key else 100
Client-side limit shared by v1 and v2. None: 30 for a free key, 100 otherwise. 0 turns it off.
On HTTP 429, wait (Retry-After, or rate_limit_wait) and retry once.
26class AsyncTransport(Protocol): 27 """Performs GET requests. Raise ``OSError`` (e.g. ``ConnectionError``) for network failures; 28 return every HTTP status, including 4xx and 5xx, as a ``Response``.""" 29 30 async def get(self, url: str, headers: Mapping[str, str]) -> Response: ... 31 32 async def aclose(self) -> None: ...
Performs GET requests. Raise OSError (e.g. ConnectionError) for network failures;
return every HTTP status, including 4xx and 5xx, as a Response.
1874def _no_init_or_replace_init(self, *args, **kwargs): 1875 cls = type(self) 1876 1877 if cls._is_protocol: 1878 raise TypeError('Protocols cannot be instantiated') 1879 1880 # Already using a custom `__init__`. No need to calculate correct 1881 # `__init__` to call. This can lead to RecursionError. See bpo-45121. 1882 if cls.__init__ is not _no_init_or_replace_init: 1883 return 1884 1885 # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`. 1886 # The first instantiation of the subclass will call `_no_init_or_replace_init` which 1887 # searches for a proper new `__init__` in the MRO. The new `__init__` 1888 # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent 1889 # instantiation of the protocol subclass will thus use the new 1890 # `__init__` and no longer call `_no_init_or_replace_init`. 1891 for base in cls.__mro__: 1892 init = base.__dict__.get('__init__', _no_init_or_replace_init) 1893 if init is not _no_init_or_replace_init: 1894 cls.__init__ = init 1895 break 1896 else: 1897 # should not happen 1898 cls.__init__ = object.__init__ 1899 1900 cls.__init__(self, *args, **kwargs)
30 async def get(self, url: str, headers: Mapping[str, str]) -> Response: ...
27class Transport(Protocol): 28 """Performs GET requests. Raise ``OSError`` (e.g. ``ConnectionError``) for network failures; 29 return every HTTP status, including 4xx and 5xx, as a ``Response``.""" 30 31 def get(self, url: str, headers: Mapping[str, str]) -> Response: ... 32 33 def close(self) -> None: ...
Performs GET requests. Raise OSError (e.g. ConnectionError) for network failures;
return every HTTP status, including 4xx and 5xx, as a Response.
1874def _no_init_or_replace_init(self, *args, **kwargs): 1875 cls = type(self) 1876 1877 if cls._is_protocol: 1878 raise TypeError('Protocols cannot be instantiated') 1879 1880 # Already using a custom `__init__`. No need to calculate correct 1881 # `__init__` to call. This can lead to RecursionError. See bpo-45121. 1882 if cls.__init__ is not _no_init_or_replace_init: 1883 return 1884 1885 # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`. 1886 # The first instantiation of the subclass will call `_no_init_or_replace_init` which 1887 # searches for a proper new `__init__` in the MRO. The new `__init__` 1888 # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent 1889 # instantiation of the protocol subclass will thus use the new 1890 # `__init__` and no longer call `_no_init_or_replace_init`. 1891 for base in cls.__mro__: 1892 init = base.__dict__.get('__init__', _no_init_or_replace_init) 1893 if init is not _no_init_or_replace_init: 1894 cls.__init__ = init 1895 break 1896 else: 1897 # should not happen 1898 cls.__init__ = object.__init__ 1899 1900 cls.__init__(self, *args, **kwargs)
31 def get(self, url: str, headers: Mapping[str, str]) -> Response: ...
35class HttpxAsyncTransport: 36 """The default transport, on httpx. Pass your own ``httpx.AsyncClient`` to share connections.""" 37 38 def __init__(self, client: httpx.AsyncClient | None = None, timeout: float = 30.0) -> None: 39 self._owned = client is None 40 self._client = client or httpx.AsyncClient(timeout=timeout) 41 42 async def get(self, url: str, headers: Mapping[str, str]) -> Response: 43 try: 44 r = await self._client.get(url, headers=dict(headers)) 45 except httpx.TransportError as e: 46 raise ConnectionError(str(e) or type(e).__name__) from e 47 return Response(r.status_code, r.text, dict(r.headers)) 48 49 async def aclose(self) -> None: 50 if self._owned: 51 await self._client.aclose()
The default transport, on httpx. Pass your own httpx.AsyncClient to share connections.
36class HttpxTransport: 37 """The default transport, on httpx. Pass your own ``httpx.Client`` to share connections.""" 38 39 def __init__(self, client: httpx.Client | None = None, timeout: float = 30.0) -> None: 40 self._owned = client is None 41 self._client = client or httpx.Client(timeout=timeout) 42 43 def get(self, url: str, headers: Mapping[str, str]) -> Response: 44 try: 45 r = self._client.get(url, headers=dict(headers)) 46 except httpx.TransportError as e: 47 raise ConnectionError(str(e) or type(e).__name__) from e 48 return Response(r.status_code, r.text, dict(r.headers)) 49 50 def close(self) -> None: 51 if self._owned: 52 self._client.close()
The default transport, on httpx. Pass your own httpx.Client to share connections.
24@dataclass(frozen=True) 25class CachePolicy: 26 """Time-to-live in seconds for each Freshness. Zero or less means don't cache.""" 27 28 static: float = 7 * 24 * 3600 29 slow: float = 24 * 3600 30 medium: float = 3600 31 live: float = 0 32 33 def ttl(self, freshness: Freshness) -> float: 34 return {Freshness.STATIC: self.static, Freshness.SLOW: self.slow, 35 Freshness.MEDIUM: self.medium, Freshness.LIVE: self.live}[freshness]
Time-to-live in seconds for each Freshness. Zero or less means don't cache.
15class Freshness(Enum): 16 """How quickly an endpoint's data changes. Every endpoint is tagged with one.""" 17 18 STATIC = "static" # sports, countries, the league list 19 SLOW = "slow" # teams, players, venues, seasons 20 MEDIUM = "medium" # schedules, standings, TV listings, highlights 21 LIVE = "live" # live scores
How quickly an endpoint's data changes. Every endpoint is tagged with one.
46class InMemoryResponseCache: 47 """A bounded, thread-safe, in-memory LRU cache with per-entry expiry.""" 48 49 def __init__(self, max_entries: int = 1000, clock: Callable[[], float] = time.monotonic) -> None: 50 self._max = max_entries 51 self._clock = clock 52 self._lock = threading.Lock() 53 self._entries: OrderedDict[str, tuple[str, float]] = OrderedDict() 54 55 def get(self, key: str) -> str | None: 56 with self._lock: 57 entry = self._entries.get(key) 58 if entry is None: 59 return None 60 body, expires = entry 61 if expires <= self._clock(): 62 del self._entries[key] 63 return None 64 self._entries.move_to_end(key) 65 return body 66 67 def put(self, key: str, body: str, ttl: float) -> None: 68 if ttl <= 0: 69 return 70 with self._lock: 71 self._entries[key] = (body, self._clock() + ttl) 72 self._entries.move_to_end(key) 73 while len(self._entries) > self._max: 74 self._entries.popitem(last=False) 75 76 def clear(self) -> None: 77 with self._lock: 78 self._entries.clear() 79 80 def __len__(self) -> int: 81 with self._lock: 82 return len(self._entries)
A bounded, thread-safe, in-memory LRU cache with per-entry expiry.
38class ResponseCache(Protocol): 39 """Stores raw response bodies. Keys never contain an API key. Implement it for Redis, disk, etc.""" 40 41 def get(self, key: str) -> str | None: ... 42 43 def put(self, key: str, body: str, ttl: float) -> None: ...
Stores raw response bodies. Keys never contain an API key. Implement it for Redis, disk, etc.
1874def _no_init_or_replace_init(self, *args, **kwargs): 1875 cls = type(self) 1876 1877 if cls._is_protocol: 1878 raise TypeError('Protocols cannot be instantiated') 1879 1880 # Already using a custom `__init__`. No need to calculate correct 1881 # `__init__` to call. This can lead to RecursionError. See bpo-45121. 1882 if cls.__init__ is not _no_init_or_replace_init: 1883 return 1884 1885 # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`. 1886 # The first instantiation of the subclass will call `_no_init_or_replace_init` which 1887 # searches for a proper new `__init__` in the MRO. The new `__init__` 1888 # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent 1889 # instantiation of the protocol subclass will thus use the new 1890 # `__init__` and no longer call `_no_init_or_replace_init`. 1891 for base in cls.__mro__: 1892 init = base.__dict__.get('__init__', _no_init_or_replace_init) 1893 if init is not _no_init_or_replace_init: 1894 cls.__init__ = init 1895 break 1896 else: 1897 # should not happen 1898 cls.__init__ = object.__init__ 1899 1900 cls.__init__(self, *args, **kwargs)
43 def put(self, key: str, body: str, ttl: float) -> None: ...
9@dataclass(frozen=True) 10class RequestEvent: 11 """One finished API call, passed to ``request_listener``.""" 12 13 url: str 14 """The URL with any v1 key replaced by ``***``. Safe to log.""" 15 status: int | None 16 """The last HTTP status; None if no response arrived or the result came from the cache.""" 17 attempts: int 18 """HTTP requests made, counting retries; 0 when served by the cache or another caller's request.""" 19 from_cache: bool 20 shared: bool 21 """True when an identical call already in flight supplied the result.""" 22 duration: float 23 """Seconds for the whole call, including rate-limit waits and retries.""" 24 error: BaseException | None
One finished API call, passed to request_listener.
The last HTTP status; None if no response arrived or the result came from the cache.
Base class for every error this library raises.
15class InvalidApiKeyError(SportsDBError): 16 """The API rejected the key (HTTP 400 ``Invalid Premium API key``). Only ``123``, ``3`` and paid keys work."""
The API rejected the key (HTTP 400 Invalid Premium API key). Only 123, 3 and paid keys work.
19class PremiumRequiredError(SportsDBError): 20 """A v2 endpoint was called with a free key. v2 accepts premium keys only. Raised before any request."""
A v2 endpoint was called with a free key. v2 accepts premium keys only. Raised before any request.
23class RateLimitError(SportsDBError): 24 """HTTP 429 after the retry was used up or disabled. ``retry_after`` is the server's wait in seconds, if given.""" 25 26 def __init__(self, message: str, retry_after: float | None) -> None: 27 super().__init__(message) 28 self.retry_after = retry_after
HTTP 429 after the retry was used up or disabled. retry_after is the server's wait in seconds, if given.
31class HTTPStatusError(SportsDBError): 32 """Any other non-2xx HTTP response, after retries for 5xx.""" 33 34 def __init__(self, message: str, status: int, body_snippet: str) -> None: 35 super().__init__(message) 36 self.status = status 37 self.body_snippet = body_snippet
Any other non-2xx HTTP response, after retries for 5xx.
40class ResponseParseError(SportsDBError): 41 """The body was not a TheSportsDB envelope (e.g. an HTML error page)."""
The body was not a TheSportsDB envelope (e.g. an HTML error page).
44class ApiMessageError(SportsDBError): 45 """The API answered with a message instead of data. 46 47 Either an unrecognised ``{"Message": ...}`` body, or a rejected parameter, which v1 48 reports as text where the records belong: ``{"seasons": "Invalid League ID passed"}``. 49 """ 50 51 def __init__(self, message: str, api_message: str) -> None: 52 super().__init__(message) 53 self.api_message = api_message
The API answered with a message instead of data.
Either an unrecognised {"Message": ...} body, or a rejected parameter, which v1
reports as text where the records belong: {"seasons": "Invalid League ID passed"}.
56class NetworkError(SportsDBError): 57 """No HTTP response arrived (DNS, connection, timeout), after retries."""
No HTTP response arrived (DNS, connection, timeout), after retries.
30@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 31class ApiRecord: 32 """Base class of every record ([Team], [Event], ...).""" 33 34 raw: Mapping[str, str | None] = field(default_factory=lambda: MappingProxyType({})) 35 """Every field as the API sent it (blank strings as None). Use it for unmodelled fields: ``team.raw["strKeywords"]``.""" 36 37 _summary: ClassVar[tuple[str, ...]] = () 38 39 def __eq__(self, other: object) -> bool: 40 return type(self) is type(other) and isinstance(other, ApiRecord) and dict(self.raw) == dict(other.raw) 41 42 def __hash__(self) -> int: 43 return hash((type(self), frozenset(self.raw.items()))) 44 45 def __repr__(self) -> str: 46 parts = ", ".join(f"{name}={getattr(self, name)!r}" for name in self._summary) 47 return f"{type(self).__name__}({parts})" 48 49 @classmethod 50 def _from(cls: type[R], raw: Mapping[str, str | None]) -> R: 51 values = cls._read(Rec(raw)) 52 return cls(raw=MappingProxyType(dict(raw)), **values) 53 54 @classmethod 55 def _read(cls, r: Rec) -> dict[str, Any]: 56 raise NotImplementedError
Base class of every record ([Team], [Event], ...).
528@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 529class Contract(ApiRecord): 530 """A player's contract.""" 531 532 _summary = ("player", "team", "year_start", "year_end") 533 id: int | None # id 534 player_id: int | None # idPlayer 535 player: str | None # strPlayer 536 team_id: int | None # idTeam 537 team: str | None # strTeam 538 badge: str | None # strBadge 539 year_start: int | None # strYearStart 540 year_end: int | None # strYearEnd 541 wage: str | None # strWage, as text 542 sport: str | None # strSport 543 544 @classmethod 545 def _read(cls, r: Rec) -> dict[str, Any]: 546 return dict(id=r.id_("id"), player_id=r.id_("idPlayer"), player=r.s("strPlayer"), team_id=r.id_("idTeam"), 547 team=r.s("strTeam"), badge=r.s("strBadge"), year_start=r.year("strYearStart"), 548 year_end=r.year("strYearEnd"), wage=r.s("strWage"), sport=r.s("strSport"))
A player's contract.
203@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 204class Country(ApiRecord): 205 """A country (``all_countries.php``, ``all/countries``). v1 fills only name and flag32.""" 206 207 _summary = ("name", "code") 208 name: str | None # name_en; use it for country filters 209 name_fr: str | None # name_fr (v2) 210 code: str | None # code (v2), e.g. "AD" 211 flag16: str | None # flag_url_16 (v2) 212 flag32: str | None # flag_url_32 213 flag64: str | None # flag_url_64 (v2) 214 api_football_id: int | None # idAPIfootball (v2) 215 216 @classmethod 217 def _read(cls, r: Rec) -> dict[str, Any]: 218 return dict(name=r.s("name_en"), name_fr=r.s("name_fr"), code=r.s("code"), flag16=r.s("flag_url_16"), 219 flag32=r.s("flag_url_32"), flag64=r.s("flag_url_64"), api_football_id=r.id_("idAPIfootball"))
A country (all_countries.php, all/countries). v1 fills only name and flag32.
614@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 615class Equipment(ApiRecord): 616 """A team kit.""" 617 618 _summary = ("team_id", "season", "type") 619 id: int | None # idEquipment 620 team_id: int | None # idTeam 621 season: str | None # strSeason 622 type: str | None # strType, e.g. 1st, 2nd, GK 623 image: str | None # strEquipment 624 uploaded_by: str | None # strUsername 625 added: DateTime | None # date (no zone stated; naive) 626 627 @classmethod 628 def _read(cls, r: Rec) -> dict[str, Any]: 629 return dict(id=r.id_("idEquipment"), team_id=r.id_("idTeam"), season=r.s("strSeason"), type=r.s("strType"), 630 image=r.s("strEquipment"), uploaded_by=r.s("strUsername"), added=r.ldt("date"))
A team kit.
693@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 694class Event(ApiRecord): 695 """A game, match, race or other event. Also used for video highlights. 696 697 ``timestamp``, ``date`` and ``time`` are UTC; ``local_date`` and ``local_time`` are the 698 venue's local time and are often missing for future events. 699 """ 700 701 _summary = ("id", "name", "timestamp", "status_code") 702 id: int | None # idEvent 703 name: str | None # strEvent, e.g. "Arsenal vs Chelsea" 704 alternate_name: str | None # strEventAlternate, e.g. "Chelsea @ Arsenal" 705 filename: str | None # strFilename 706 sport: str | None # strSport 707 league_id: int | None # idLeague 708 league: str | None # strLeague 709 league_badge: str | None # strLeagueBadge 710 season: str | None # strSeason 711 round: int | None # intRound: round number, or a stage code (125 quarter-final … 500 pre-season); see stage 712 group: str | None # strGroup 713 home_team_id: int | None # idHomeTeam 714 home_team: str | None # strHomeTeam 715 home_team_badge: str | None # strHomeTeamBadge 716 away_team_id: int | None # idAwayTeam 717 away_team: str | None # strAwayTeam 718 away_team_badge: str | None # strAwayTeamBadge 719 home_score: int | None # intHomeScore 720 away_score: int | None # intAwayScore 721 home_score_extra: int | None # intHomeScoreExtra 722 away_score_extra: int | None # intAwayScoreExtra 723 timestamp: DateTime | None # strTimestamp: start, UTC (aware) 724 date: Date | None # dateEvent (UTC) 725 time: Time | None # strTime (UTC) 726 local_date: Date | None # dateEventLocal 727 local_time: Time | None # strTimeLocal 728 status_code: str | None # strStatus: NS, 2H, FT, Q3, PST...; older events often have none 729 is_postponed: bool | None # strPostponed 730 venue_id: int | None # idVenue 731 venue: str | None # strVenue 732 city: str | None # strCity 733 country: str | None # strCountry 734 spectators: int | None # intSpectators 735 official: str | None # strOfficial 736 result_text: str | None # strResult 737 description: str | None # strDescriptionEN 738 thumb: str | None # strThumb 739 poster: str | None # strPoster 740 banner: str | None # strBanner 741 square: str | None # strSquare 742 fanart: str | None # strFanart 743 map: str | None # strMap 744 video: str | None # strVideo: highlights, usually YouTube 745 tweet: str | None # strTweet1 746 weather: str | None # strWeather 747 rating: float | None # intScore 748 rating_votes: int | None # intScoreVotes 749 is_locked: bool | None # strLocked 750 api_football_id: int | None # idAPIfootball 751 752 @property 753 def status(self) -> EventStatus: 754 return EventStatus.of(self.status_code) 755 756 @property 757 def stage(self) -> RoundStage | None: 758 """The stage when ``round`` is a stage code (e.g. 200 = final), or None for an ordinary round.""" 759 return RoundStage.of(self.round) 760 761 @classmethod 762 def _read(cls, r: Rec) -> dict[str, Any]: 763 return dict( 764 id=r.id_("idEvent"), name=r.s("strEvent"), alternate_name=r.s("strEventAlternate"), 765 filename=r.s("strFilename"), sport=r.s("strSport"), league_id=r.id_("idLeague"), league=r.s("strLeague"), 766 league_badge=r.s("strLeagueBadge"), season=r.s("strSeason"), round=r.i("intRound"), group=r.s("strGroup"), 767 home_team_id=r.id_("idHomeTeam"), home_team=r.s("strHomeTeam"), home_team_badge=r.s("strHomeTeamBadge"), 768 away_team_id=r.id_("idAwayTeam"), away_team=r.s("strAwayTeam"), away_team_badge=r.s("strAwayTeamBadge"), 769 home_score=r.i("intHomeScore"), away_score=r.i("intAwayScore"), 770 home_score_extra=r.i("intHomeScoreExtra"), away_score_extra=r.i("intAwayScoreExtra"), 771 timestamp=r.ts("strTimestamp"), date=r.d("dateEvent"), time=r.t("strTime"), 772 local_date=r.d("dateEventLocal"), local_time=r.t("strTimeLocal"), status_code=r.s("strStatus"), 773 is_postponed=r.b("strPostponed"), venue_id=r.id_("idVenue"), venue=r.s("strVenue"), city=r.s("strCity"), 774 country=r.s("strCountry"), spectators=r.i("intSpectators"), official=r.s("strOfficial"), 775 result_text=r.s("strResult"), description=r.s("strDescriptionEN"), thumb=r.s("strThumb"), 776 poster=r.s("strPoster"), banner=r.s("strBanner"), square=r.s("strSquare"), fanart=r.s("strFanart"), 777 map=r.s("strMap"), video=r.s("strVideo"), tweet=r.s("strTweet1"), weather=r.s("strWeather"), 778 rating=r.f("intScore"), rating_votes=r.i("intScoreVotes"), is_locked=r.locked(), 779 api_football_id=r.id_("idAPIfootball"), 780 )
A game, match, race or other event. Also used for video highlights.
timestamp, date and time are UTC; local_date and local_time are the
venue's local time and are often missing for future events.
756 @property 757 def stage(self) -> RoundStage | None: 758 """The stage when ``round`` is a stage code (e.g. 200 = final), or None for an ordinary round.""" 759 return RoundStage.of(self.round)
The stage when round is a stage code (e.g. 200 = final), or None for an ordinary round.
551@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 552class EventResult(ApiRecord): 553 """One competitor's result in an individual-sport event (race, golf, fight).""" 554 555 _summary = ("event_id", "player", "position") 556 id: int | None # idResult 557 event_id: int | None # idEvent 558 event: str | None # strEvent 559 date: Date | None # dateEvent 560 season: str | None # strSeason 561 sport: str | None # strSport 562 country: str | None # strCountry 563 player_id: int | None # idPlayer 564 player: str | None # strPlayer 565 team_id: int | None # idTeam 566 position: int | None # intPosition 567 points: int | None # intPoints 568 result: str | None # strResult 569 detail: str | None # strDetail, e.g. a time gap "+29.520" 570 571 @classmethod 572 def _read(cls, r: Rec) -> dict[str, Any]: 573 return dict(id=r.id_("idResult"), event_id=r.id_("idEvent"), event=r.s("strEvent"), date=r.d("dateEvent"), 574 season=r.s("strSeason"), sport=r.s("strSport"), country=r.s("strCountry"), 575 player_id=r.id_("idPlayer"), player=r.s("strPlayer"), team_id=r.id_("idTeam"), 576 position=r.i("intPosition"), points=r.i("intPoints"), result=r.s("strResult"), 577 detail=r.s("strDetail"))
One competitor's result in an individual-sport event (race, golf, fight).
891@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 892class EventStat(ApiRecord): 893 """One team statistic for an event, home vs away.""" 894 895 _summary = ("event_id", "name", "home", "away") 896 id: int | None # idStatistic 897 event_id: int | None # idEvent 898 event: str | None # strEvent 899 name: str | None # strStat 900 home: float | None # intHome 901 away: float | None # intAway 902 api_football_id: int | None # idApiFootball (note the capitalisation) 903 904 @classmethod 905 def _read(cls, r: Rec) -> dict[str, Any]: 906 return dict(id=r.id_("idStatistic"), event_id=r.id_("idEvent"), event=r.s("strEvent"), name=r.s("strStat"), 907 home=r.f("intHome"), away=r.f("intAway"), api_football_id=r.id_("idApiFootball"))
One team statistic for an event, home vs away.
117class EventStatus(Enum): 118 """A broad reading of a ``strStatus`` code, so you don't need every sport's codes. 119 120 Covers the codes in TheSportsDB's data documentation (docs_api_data) for every sport. 121 """ 122 123 NOT_STARTED = "not_started" 124 IN_PLAY = "in_play" 125 FINISHED = "finished" 126 POSTPONED = "postponed" 127 INTERRUPTED = "interrupted" # suspended or interrupted (SUSP, INT, INTR): stopped, and may resume 128 CANCELLED = "cancelled" 129 ABANDONED = "abandoned" 130 UNKNOWN = "unknown" # None, or a code this library doesn't know 131 132 @staticmethod 133 def of(code: str | None) -> EventStatus: 134 """Classifies a raw code (``NS``, ``2H``, ``Q3``, ``P2``, ``IN4``, ``S2``, ``FT``, ``PST``...).""" 135 c = (code or "").strip().upper() 136 if not c: 137 return EventStatus.UNKNOWN 138 for status, codes in _STATUS_CODES: 139 if c in codes: 140 return status 141 if re.fullmatch(r"IN\d+|S\d", c): # baseball innings, volleyball sets 142 return EventStatus.IN_PLAY 143 return EventStatus.UNKNOWN
A broad reading of a strStatus code, so you don't need every sport's codes.
Covers the codes in TheSportsDB's data documentation (docs_api_data) for every sport.
132 @staticmethod 133 def of(code: str | None) -> EventStatus: 134 """Classifies a raw code (``NS``, ``2H``, ``Q3``, ``P2``, ``IN4``, ``S2``, ``FT``, ``PST``...).""" 135 c = (code or "").strip().upper() 136 if not c: 137 return EventStatus.UNKNOWN 138 for status, codes in _STATUS_CODES: 139 if c in codes: 140 return status 141 if re.fullmatch(r"IN\d+|S\d", c): # baseball innings, volleyball sets 142 return EventStatus.IN_PLAY 143 return EventStatus.UNKNOWN
Classifies a raw code (NS, 2H, Q3, P2, IN4, S2, FT, PST...).
478@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 479class FormerTeam(ApiRecord): 480 """A team a player used to play for (``lookupformerteams.php``, ``lookup/player_teams``).""" 481 482 _summary = ("player", "team", "joined", "departed") 483 id: int | None # id 484 player_id: int | None # idPlayer 485 player: str | None # strPlayer 486 team_id: int | None # idFormerTeam 487 team: str | None # strFormerTeam 488 badge: str | None # strBadge 489 joined: str | None # strJoined, usually a year 490 departed: str | None # strDeparted, usually a year 491 move_type: str | None # strMoveType, e.g. Permanent, Loan 492 appearances: int | None # intAppearances 493 goals: int | None # intGoals 494 sport: str | None # strSport 495 496 @classmethod 497 def _read(cls, r: Rec) -> dict[str, Any]: 498 return dict(id=r.id_("id"), player_id=r.id_("idPlayer"), player=r.s("strPlayer"), 499 team_id=r.id_("idFormerTeam"), team=r.s("strFormerTeam"), badge=r.s("strBadge"), 500 joined=r.s("strJoined"), departed=r.s("strDeparted"), move_type=r.s("strMoveType"), 501 appearances=r.i("intAppearances"), goals=r.i("intGoals"), sport=r.s("strSport"))
A team a player used to play for (lookupformerteams.php, lookup/player_teams).
451@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 452class Honour(ApiRecord): 453 """A trophy a player won (``lookuphonours.php``, ``lookup/player_honours``).""" 454 455 _summary = ("player", "name", "season") 456 id: int | None # id 457 honour_id: int | None # idHonour 458 name: str | None # strHonour 459 season: str | None # strSeason 460 player_id: int | None # idPlayer 461 player: str | None # strPlayer 462 team_id: int | None # idTeam 463 team: str | None # strTeam 464 team_badge: str | None # strTeamBadge 465 league_id: int | None # idLeague 466 sport: str | None # strSport 467 logo: str | None # strHonourLogo 468 trophy: str | None # strHonourTrophy 469 470 @classmethod 471 def _read(cls, r: Rec) -> dict[str, Any]: 472 return dict(id=r.id_("id"), honour_id=r.id_("idHonour"), name=r.s("strHonour"), season=r.s("strSeason"), 473 player_id=r.id_("idPlayer"), player=r.s("strPlayer"), team_id=r.id_("idTeam"), 474 team=r.s("strTeam"), team_badge=r.s("strTeamBadge"), league_id=r.id_("idLeague"), 475 sport=r.s("strSport"), logo=r.s("strHonourLogo"), trophy=r.s("strHonourTrophy"))
A trophy a player won (lookuphonours.php, lookup/player_honours).
96class ImageSize(Enum): 97 """Sizes TheSportsDB serves by appending a path suffix to an image URL.""" 98 99 MEDIUM = "/medium" # about 70% of the bytes 100 SMALL = "/small" # about 35%; good for cards 101 TINY = "/tiny" # about 10%; good for lists
Sizes TheSportsDB serves by appending a path suffix to an image URL.
222@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 223class League(ApiRecord): 224 """A league or cup. Lists fill only a few fields; look it up by id for the rest.""" 225 226 _summary = ("id", "name") 227 id: int | None # idLeague 228 name: str | None # strLeague 229 alternate_names: tuple[str, ...] # strLeagueAlternate, split on commas (premium only in all_leagues.php) 230 sport: str | None # strSport 231 country: str | None # strCountry 232 gender: str | None # strGender 233 current_season: str | None # strCurrentSeason, e.g. "2026-2027" or "2026" 234 division: int | None # intDivision 235 is_cup: bool | None # idCup 236 formed_year: int | None # intFormedYear 237 first_event_date: Date | None # dateFirstEvent 238 is_complete: bool | None # strComplete 239 event_naming: str | None # strNaming, e.g. "{strHomeTeam} vs {strAwayTeam}" 240 tv_rights: str | None # strTvRights 241 badge: str | None # strBadge 242 logo: str | None # strLogo 243 banner: str | None # strBanner 244 poster: str | None # strPoster 245 trophy: str | None # strTrophy 246 fanart: tuple[str, ...] # strFanart1..4 247 descriptions: Mapping[str, str] # strDescriptionEN, DE, ... keyed by language code 248 socials: Socials 249 is_locked: bool | None # strLocked 250 api_football_id: int | None # idAPIfootball 251 api_football_v3_id: int | None # idAPIfootballv3 252 253 @property 254 def description(self) -> str | None: 255 """The English description.""" 256 return self.descriptions.get("EN") 257 258 @classmethod 259 def _read(cls, r: Rec) -> dict[str, Any]: 260 return dict( 261 id=r.id_("idLeague"), name=r.s("strLeague"), alternate_names=r.csv("strLeagueAlternate"), 262 sport=r.s("strSport"), country=r.s("strCountry"), gender=r.s("strGender"), 263 current_season=r.s("strCurrentSeason"), division=r.i("intDivision"), is_cup=r.b("idCup"), 264 formed_year=r.year("intFormedYear"), first_event_date=r.d("dateFirstEvent"), 265 is_complete=r.b("strComplete"), event_naming=r.s("strNaming"), tv_rights=r.s("strTvRights"), 266 badge=r.s("strBadge"), logo=r.s("strLogo"), banner=r.s("strBanner"), poster=r.s("strPoster"), 267 trophy=r.s("strTrophy"), fanart=r.numbered("strFanart", 4), 268 descriptions=MappingProxyType(r.descriptions()), socials=Socials._read(r), is_locked=r.locked(), 269 api_football_id=r.id_("idAPIfootball"), api_football_v3_id=r.id_("idAPIfootballv3"), 270 )
A league or cup. Lists fill only a few fields; look it up by id for the rest.
76@dataclass(frozen=True) 77class LeagueRef: 78 """A league a team plays in: one of ``idLeague``/``strLeague`` .. ``idLeague7``/``strLeague7``.""" 79 80 id: int 81 name: str | None
A league a team plays in: one of idLeague/strLeague .. idLeague7/strLeague7.
820@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 821class LineupEntry(ApiRecord): 822 """One player in an event's lineup.""" 823 824 _summary = ("event_id", "player", "team") 825 id: int | None # idLineup 826 event_id: int | None # idEvent 827 event: str | None # strEvent (v2) 828 player_id: int | None # idPlayer 829 player: str | None # strPlayer 830 team_id: int | None # idTeam 831 team: str | None # strTeam 832 is_home: bool | None # strHome 833 is_substitute: bool | None # strSubstitute 834 position: str | None # strPosition 835 position_short: str | None # strPositionShort (v2; often null) 836 formation: str | None # strFormation (v2; often null) 837 squad_number: int | None # intSquadNumber 838 country: str | None # strCountry (v2) 839 season: str | None # strSeason (v2) 840 thumb: str | None # strThumb (v1) 841 cutout: str | None # strCutout 842 render: str | None # strRender (v1) 843 api_football_id: int | None # idAPIfootball (v2) 844 845 @classmethod 846 def _read(cls, r: Rec) -> dict[str, Any]: 847 return dict(id=r.id_("idLineup"), event_id=r.id_("idEvent"), event=r.s("strEvent"), 848 player_id=r.id_("idPlayer"), player=r.s("strPlayer"), team_id=r.id_("idTeam"), 849 team=r.s("strTeam"), is_home=r.b("strHome"), is_substitute=r.b("strSubstitute"), 850 position=r.s("strPosition"), position_short=r.s("strPositionShort"), 851 formation=r.s("strFormation"), squad_number=r.i("intSquadNumber"), country=r.s("strCountry"), 852 season=r.s("strSeason"), thumb=r.s("strThumb"), cutout=r.s("strCutout"), 853 render=r.s("strRender"), api_football_id=r.id_("idAPIfootball"))
One player in an event's lineup.
945@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 946class LiveScore(ApiRecord): 947 """A game in progress. Entries can be stale: check ``updated``.""" 948 949 _summary = ("event_id", "home_team", "home_score", "away_score", "away_team", "status_code") 950 id: int | None # idLiveScore 951 event_id: int | None # idEvent 952 sport: str | None # strSport 953 league_id: int | None # idLeague 954 league: str | None # strLeague 955 division: int | None # intDivision 956 home_team_id: int | None # idHomeTeam 957 home_team: str | None # strHomeTeam 958 home_team_badge: str | None # strHomeTeamBadge 959 away_team_id: int | None # idAwayTeam 960 away_team: str | None # strAwayTeam 961 away_team_badge: str | None # strAwayTeamBadge 962 home_score: int | None # intHomeScore 963 away_score: int | None # intAwayScore 964 status_code: str | None # strStatus 965 progress: str | None # strProgress: the minute, or "Final" 966 event_time: Time | None # strEventTime, HH:mm 967 date: Date | None # dateEvent 968 timestamp: DateTime | None # strTimestamp: kick-off, UTC 969 updated: DateTime | None # updated (naive; zone not stated) 970 971 @property 972 def status(self) -> EventStatus: 973 return EventStatus.of(self.status_code) 974 975 @classmethod 976 def _read(cls, r: Rec) -> dict[str, Any]: 977 return dict(id=r.id_("idLiveScore"), event_id=r.id_("idEvent"), sport=r.s("strSport"), 978 league_id=r.id_("idLeague"), league=r.s("strLeague"), division=r.i("intDivision"), 979 home_team_id=r.id_("idHomeTeam"), home_team=r.s("strHomeTeam"), 980 home_team_badge=r.s("strHomeTeamBadge"), away_team_id=r.id_("idAwayTeam"), 981 away_team=r.s("strAwayTeam"), away_team_badge=r.s("strAwayTeamBadge"), 982 home_score=r.i("intHomeScore"), away_score=r.i("intAwayScore"), status_code=r.s("strStatus"), 983 progress=r.s("strProgress"), event_time=r.t("strEventTime"), date=r.d("dateEvent"), 984 timestamp=r.ts("strTimestamp"), updated=r.ldt("updated"))
A game in progress. Entries can be stale: check updated.
504@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 505class Milestone(ApiRecord): 506 """A career milestone or award.""" 507 508 _summary = ("player", "name", "date") 509 id: int | None # id 510 milestone_id: int | None # idMilestone 511 name: str | None # strMilestone 512 date: Date | None # dateMilestone 513 player_id: int | None # idPlayer 514 player: str | None # strPlayer 515 team_id: int | None # idTeam 516 team: str | None # strTeam 517 sport: str | None # strSport 518 logo: str | None # strMilestoneLogo 519 520 @classmethod 521 def _read(cls, r: Rec) -> dict[str, Any]: 522 return dict(id=r.id_("id"), milestone_id=r.id_("idMilestone"), name=r.s("strMilestone"), 523 date=r.d("dateMilestone"), player_id=r.id_("idPlayer"), player=r.s("strPlayer"), 524 team_id=r.id_("idTeam"), team=r.s("strTeam"), sport=r.s("strSport"), 525 logo=r.s("strMilestoneLogo"))
A career milestone or award.
371@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 372class Player(ApiRecord): 373 """A player, manager or other person. Search and list endpoints fill only some fields.""" 374 375 _summary = ("id", "name", "team") 376 id: int | None # idPlayer 377 name: str | None # strPlayer 378 alternate_name: str | None # strPlayerAlternate 379 last_name: str | None # strLastName 380 team_id: int | None # idTeam 381 team: str | None # strTeam 382 second_team_id: int | None # idTeam2 383 second_team: str | None # strTeam2, often the national team 384 national_team_id: int | None # idTeamNational 385 manager_id: int | None # idPlayerManager 386 sport: str | None # strSport 387 nationality: str | None # strNationality 388 position: str | None # strPosition 389 number: str | None # strNumber, as text 390 status: str | None # strStatus, e.g. Active, Retired 391 gender: str | None # strGender 392 born: Date | None # dateBorn 393 birth_location: str | None # strBirthLocation 394 died: Date | None # dateDied 395 death_location: str | None # strDeathLocation 396 signed: Date | None # dateSigned 397 signing: str | None # strSigning: the fee as text 398 wage: str | None # strWage, as text 399 agent: str | None # strAgent 400 height: str | None # strHeight, as text 401 weight: str | None # strWeight, as text 402 side: str | None # strSide: Left/Right 403 kit: str | None # strKit 404 outfitter: str | None # strOutfitter 405 college: str | None # strCollege 406 ethnicity: str | None # strEthnicity 407 thumb: str | None # strThumb 408 cutout: str | None # strCutout 409 render: str | None # strRender 410 cartoon: str | None # strCartoon 411 banner: str | None # strBanner 412 poster: str | None # strPoster 413 fanart: tuple[str, ...] # strFanart1..4 414 creative_commons: bool | None 415 """strCreativeCommons. TheSportsDB's terms: artwork that isn't Creative Commons must not be used in published apps.""" 416 creative_commons_attribution: str | None # strCreativeCommonsAttribution: the credit to show 417 descriptions: Mapping[str, str] 418 socials: Socials 419 loved: int | None # intLoved 420 is_locked: bool | None # strLocked 421 relevance: float | None # relevance: search score (v1 searchplayers.php only) 422 external_ids: PlayerExternalIds 423 424 @property 425 def description(self) -> str | None: 426 return self.descriptions.get("EN") 427 428 @classmethod 429 def _read(cls, r: Rec) -> dict[str, Any]: 430 return dict( 431 id=r.id_("idPlayer"), name=r.s("strPlayer"), alternate_name=r.s("strPlayerAlternate"), 432 last_name=r.s("strLastName"), team_id=r.id_("idTeam"), team=r.s("strTeam"), 433 second_team_id=r.id_("idTeam2"), second_team=r.s("strTeam2"), national_team_id=r.id_("idTeamNational"), 434 manager_id=r.id_("idPlayerManager"), sport=r.s("strSport"), nationality=r.s("strNationality"), 435 position=r.s("strPosition"), number=r.s("strNumber"), status=r.s("strStatus"), gender=r.s("strGender"), 436 born=r.d("dateBorn"), birth_location=r.s("strBirthLocation"), died=r.d("dateDied"), 437 death_location=r.s("strDeathLocation"), signed=r.d("dateSigned"), signing=r.s("strSigning"), 438 wage=r.s("strWage"), agent=r.s("strAgent"), height=r.s("strHeight"), weight=r.s("strWeight"), 439 side=r.s("strSide"), kit=r.s("strKit"), outfitter=r.s("strOutfitter"), college=r.s("strCollege"), 440 ethnicity=r.s("strEthnicity"), thumb=r.s("strThumb"), cutout=r.s("strCutout"), render=r.s("strRender"), 441 cartoon=r.s("strCartoon"), banner=r.s("strBanner"), poster=r.s("strPoster"), 442 fanart=r.numbered("strFanart", 4), creative_commons=r.b("strCreativeCommons"), 443 creative_commons_attribution=r.s("strCreativeCommonsAttribution"), 444 descriptions=MappingProxyType(r.descriptions()), socials=Socials._read(r), loved=r.i("intLoved"), 445 is_locked=r.locked(), relevance=r.f("relevance"), 446 external_ids=PlayerExternalIds(r.id_("idAPIfootball"), r.s("idESPN"), r.s("idGoogle"), 447 r.s("idTransferMkt"), r.s("idWikidata"), r.s("intSoccerXMLTeamID")), 448 )
A player, manager or other person. Search and list endpoints fill only some fields.
strCreativeCommons. TheSportsDB's terms: artwork that isn't Creative Commons must not be used in published apps.
84@dataclass(frozen=True) 85class PlayerExternalIds: 86 """Ids of the same player in other databases.""" 87 88 api_football: int | None = None # idAPIfootball 89 espn: str | None = None # idESPN 90 google: str | None = None # idGoogle, e.g. /g/11cpprmr81 91 transfermarkt: str | None = None # idTransferMkt 92 wikidata: str | None = None # idWikidata, e.g. Q9144353 93 soccer_xml_team: str | None = None # intSoccerXMLTeamID
Ids of the same player in other databases.
580@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 581class PlayerStat(ApiRecord): 582 """One statistic for a player in one season.""" 583 584 _summary = ("player", "season", "statistic", "value") 585 id: int | None # id 586 player_id: int | None # idPlayer 587 player: str | None # strPlayer 588 team_id: int | None # idTeam 589 team: str | None # strTeam 590 team_badge: str | None # strTeamBadge 591 league_id: int | None # idLeague 592 league: str | None # strLeague 593 league_badge: str | None # strLeagueBadge 594 season: str | None # strSeason 595 sport: str | None # strSport 596 statistic: str | None # strStatistic, e.g. Goals 597 value: str | None # strValue, as text 598 599 @property 600 def numeric_value(self) -> float | None: 601 try: 602 return float(self.value) if self.value is not None else None 603 except ValueError: 604 return None 605 606 @classmethod 607 def _read(cls, r: Rec) -> dict[str, Any]: 608 return dict(id=r.id_("id"), player_id=r.id_("idPlayer"), player=r.s("strPlayer"), team_id=r.id_("idTeam"), 609 team=r.s("strTeam"), team_badge=r.s("strTeamBadge"), league_id=r.id_("idLeague"), 610 league=r.s("strLeague"), league_badge=r.s("strLeagueBadge"), season=r.s("strSeason"), 611 sport=r.s("strSport"), statistic=r.s("strStatistic"), value=r.s("strValue"))
One statistic for a player in one season.
158class RoundStage(Enum): 159 """The stage a special ``intRound`` value stands for (docs_api_data). Other values are ordinary rounds.""" 160 161 QUARTER_FINAL = 125 162 SEMI_FINAL = 150 163 PLAYOFF = 160 164 PLAYOFF_SEMI_FINAL = 170 165 PLAYOFF_FINAL = 180 166 FINAL = 200 167 QUALIFIER = 400 168 PRE_SEASON = 500 169 170 @staticmethod 171 def of(round: int | None) -> RoundStage | None: 172 """The stage for an ``intRound`` value, or None for an ordinary round number.""" 173 try: 174 return RoundStage(round) if round is not None else None 175 except ValueError: 176 return None
The stage a special intRound value stands for (docs_api_data). Other values are ordinary rounds.
170 @staticmethod 171 def of(round: int | None) -> RoundStage | None: 172 """The stage for an ``intRound`` value, or None for an ordinary round number.""" 173 try: 174 return RoundStage(round) if round is not None else None 175 except ValueError: 176 return None
The stage for an intRound value, or None for an ordinary round number.
273@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 274class Season(ApiRecord): 275 """A season of a league (``search_all_seasons.php``, ``list/seasons``).""" 276 277 _summary = ("name",) 278 name: str # strSeason, e.g. "2026-2027" 279 badge: str | None # strBadge (v2, or v1 with badges=True) 280 poster: str | None # strPoster (v2, or v1 with posters=True) 281 description: str | None # strDescriptionEN (v2, or v1 with descriptions=True) 282 283 @classmethod 284 def _read(cls, r: Rec) -> dict[str, Any]: 285 return dict(name=r.s("strSeason") or "", badge=r.s("strBadge"), poster=r.s("strPoster"), 286 description=r.s("strDescriptionEN"))
A season of a league (search_all_seasons.php, list/seasons).
289@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 290class SeasonPoster(ApiRecord): 291 """One season poster or badge uploaded for a league (v2 ``list/seasonposters``); several per season are possible.""" 292 293 _summary = ("id", "season") 294 id: int | None # idArt: this artwork's id 295 league_id: int | None # idLeague 296 season: str | None # strSeason 297 poster: str | None # strPoster 298 badge: str | None # strBadge 299 description: str | None # strDescriptionEN 300 uploaded_by: str | None # strUsername 301 302 @classmethod 303 def _read(cls, r: Rec) -> dict[str, Any]: 304 return dict(id=r.id_("idArt"), league_id=r.id_("idLeague"), season=r.s("strSeason"), poster=r.s("strPoster"), 305 badge=r.s("strBadge"), description=r.s("strDescriptionEN"), uploaded_by=r.s("strUsername"))
One season poster or badge uploaded for a league (v2 list/seasonposters); several per season are possible.
59@dataclass(frozen=True) 60class Socials: 61 """Web and social links. Often without a scheme (``www.facebook.com/Arsenal``).""" 62 63 website: str | None = None # strWebsite 64 facebook: str | None = None # strFacebook 65 twitter: str | None = None # strTwitter 66 instagram: str | None = None # strInstagram 67 youtube: str | None = None # strYoutube 68 rss: str | None = None # strRSS 69 70 @staticmethod 71 def _read(r: Rec) -> Socials: 72 return Socials(r.s("strWebsite"), r.s("strFacebook"), r.s("strTwitter"), r.s("strInstagram"), 73 r.s("strYoutube"), r.s("strRSS"))
Web and social links. Often without a scheme (www.facebook.com/Arsenal).
183@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 184class Sport(ApiRecord): 185 """A sport (``all_sports.php``, ``all/sports``).""" 186 187 _summary = ("id", "name") 188 id: int | None # idSport 189 name: str | None # strSport, e.g. "Ice Hockey"; use it for sport filters 190 format: str | None # strFormat: TeamvsTeam or EventSport 191 description: str | None # strSportDescription 192 thumb: str | None # strSportThumb 193 thumb_black_and_white: str | None # strSportThumbBW 194 icon: str | None # strSportIconGreen 195 196 @classmethod 197 def _read(cls, r: Rec) -> dict[str, Any]: 198 return dict(id=r.id_("idSport"), name=r.s("strSport"), format=r.s("strFormat"), 199 description=r.s("strSportDescription"), thumb=r.s("strSportThumb"), 200 thumb_black_and_white=r.s("strSportThumbBW"), icon=r.s("strSportIconGreen"))
A sport (all_sports.php, all/sports).
783@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 784class Standing(ApiRecord): 785 """One row of a league table (``lookuptable.php``; v1 only).""" 786 787 _summary = ("rank", "team", "points") 788 id: int | None # idStanding 789 rank: int | None # intRank 790 team_id: int | None # idTeam 791 team: str | None # strTeam 792 badge: str | None # strBadge 793 league_id: int | None # idLeague 794 league: str | None # strLeague 795 season: str | None # strSeason 796 group: str | None # strGroup 797 form: str | None # strForm, e.g. "WWDLW" 798 description: str | None # strDescription, e.g. "Promotion - Champions League" 799 played: int | None # intPlayed 800 won: int | None # intWin 801 drawn: int | None # intDraw 802 lost: int | None # intLoss 803 goals_for: int | None # intGoalsFor 804 goals_against: int | None # intGoalsAgainst 805 goal_difference: int | None # intGoalDifference 806 points: int | None # intPoints 807 updated: DateTime | None # dateUpdated (naive) 808 809 @classmethod 810 def _read(cls, r: Rec) -> dict[str, Any]: 811 return dict(id=r.id_("idStanding"), rank=r.i("intRank"), team_id=r.id_("idTeam"), team=r.s("strTeam"), 812 badge=r.s("strBadge"), league_id=r.id_("idLeague"), league=r.s("strLeague"), 813 season=r.s("strSeason"), group=r.s("strGroup"), form=r.s("strForm"), 814 description=r.s("strDescription"), played=r.i("intPlayed"), won=r.i("intWin"), 815 drawn=r.i("intDraw"), lost=r.i("intLoss"), goals_for=r.i("intGoalsFor"), 816 goals_against=r.i("intGoalsAgainst"), goal_difference=r.i("intGoalDifference"), 817 points=r.i("intPoints"), updated=r.ldt("dateUpdated"))
One row of a league table (lookuptable.php; v1 only).
308@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 309class Team(ApiRecord): 310 """A team. Search and list endpoints fill only some fields; a lookup by id fills them all.""" 311 312 _summary = ("id", "name") 313 id: int | None # idTeam 314 name: str | None # strTeam 315 short_name: str | None # strTeamShort, e.g. "ARS" 316 alternate_names: tuple[str, ...] # strTeamAlternate, split on commas 317 keywords: tuple[str, ...] # strKeywords, split on commas (nicknames) 318 sport: str | None # strSport 319 gender: str | None # strGender 320 country: str | None # strCountry 321 location: str | None # strLocation 322 formed_year: int | None # intFormedYear 323 league_id: int | None # idLeague: the main league 324 league: str | None # strLeague 325 leagues: tuple[LeagueRef, ...] # idLeague..idLeague7 with their names 326 division: str | None # strDivision 327 venue_id: int | None # idVenue 328 stadium: str | None # strStadium 329 colours: tuple[str, ...] # strColour1..3, hex like "#EF0107" 330 badge: str | None # strBadge 331 logo: str | None # strLogo 332 banner: str | None # strBanner 333 equipment: str | None # strEquipment: the current kit image 334 fanart: tuple[str, ...] # strFanart1..4 335 descriptions: Mapping[str, str] # strDescriptionEN, ... 336 socials: Socials 337 loved: int | None # intLoved 338 is_locked: bool | None # strLocked 339 api_football_id: int | None # idAPIfootball 340 espn_id: str | None # idESPN 341 342 @property 343 def description(self) -> str | None: 344 return self.descriptions.get("EN") 345 346 @classmethod 347 def _read(cls, r: Rec) -> dict[str, Any]: 348 leagues = [] 349 for n in range(1, 8): 350 suffix = "" if n == 1 else str(n) 351 if (lid := r.id_(f"idLeague{suffix}")) is not None: 352 leagues.append(LeagueRef(lid, r.s(f"strLeague{suffix}"))) 353 return dict( 354 id=r.id_("idTeam"), name=r.s("strTeam"), short_name=r.s("strTeamShort"), 355 alternate_names=r.csv("strTeamAlternate"), keywords=r.csv("strKeywords"), sport=r.s("strSport"), 356 gender=r.s("strGender"), country=r.s("strCountry"), location=r.s("strLocation"), 357 formed_year=r.year("intFormedYear"), league_id=r.id_("idLeague"), league=r.s("strLeague"), 358 leagues=tuple(leagues), division=r.s("strDivision"), venue_id=r.id_("idVenue"), 359 stadium=r.s("strStadium"), colours=r.numbered("strColour", 3), badge=r.s("strBadge"), 360 logo=r.s("strLogo"), banner=r.s("strBanner"), equipment=r.s("strEquipment"), 361 fanart=r.numbered("strFanart", 4), descriptions=MappingProxyType(r.descriptions()), 362 socials=Socials._read(r), loved=r.i("intLoved"), is_locked=r.locked(), 363 api_football_id=r.id_("idAPIfootball"), espn_id=r.s("idESPN"), 364 )
A team. Search and list endpoints fill only some fields; a lookup by id fills them all.
856@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 857class TimelineEntry(ApiRecord): 858 """A goal, card or substitution during an event.""" 859 860 _summary = ("event_id", "minute", "type", "player") 861 id: int | None # idTimeline 862 event_id: int | None # idEvent 863 event: str | None # strEvent 864 date: Date | None # dateEvent 865 season: str | None # strSeason 866 minute: int | None # intTime 867 period: str | None # strPeriod 868 type: str | None # strTimeline, e.g. Goal, Card, subst 869 detail: str | None # strTimelineDetail 870 comment: str | None # strComment 871 player_id: int | None # idPlayer 872 player: str | None # strPlayer 873 cutout: str | None # strCutout 874 assist_id: int | None # idAssist 875 assist: str | None # strAssist 876 team_id: int | None # idTeam 877 team: str | None # strTeam 878 is_home: bool | None # strHome 879 api_football_id: int | None # idAPIfootball 880 881 @classmethod 882 def _read(cls, r: Rec) -> dict[str, Any]: 883 return dict(id=r.id_("idTimeline"), event_id=r.id_("idEvent"), event=r.s("strEvent"), date=r.d("dateEvent"), 884 season=r.s("strSeason"), minute=r.i("intTime"), period=r.s("strPeriod"), 885 type=r.s("strTimeline"), detail=r.s("strTimelineDetail"), comment=r.s("strComment"), 886 player_id=r.id_("idPlayer"), player=r.s("strPlayer"), cutout=r.s("strCutout"), 887 assist_id=r.id_("idAssist"), assist=r.s("strAssist"), team_id=r.id_("idTeam"), 888 team=r.s("strTeam"), is_home=r.b("strHome"), api_football_id=r.id_("idAPIfootball"))
A goal, card or substitution during an event.
910@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 911class TvListing(ApiRecord): 912 """One broadcast of an event on one channel.""" 913 914 _summary = ("event_id", "channel", "timestamp") 915 id: int | None # id 916 event_id: int | None # idEvent 917 event: str | None # strEvent 918 sport: str | None # strSport 919 season: str | None # strSeason 920 channel_id: int | None # idChannel 921 channel: str | None # strChannel, e.g. "TSN 1" 922 channel_logo: str | None # strLogo 923 country: str | None # strCountry: the channel's country 924 event_country: str | None # strEventCountry 925 timestamp: DateTime | None # strTimeStamp (capital S, space-separated): UTC 926 date: Date | None # dateEvent 927 time: Time | None # strTime 928 division: int | None # intDivision 929 event_thumb: str | None # strEventThumb 930 event_poster: str | None # strEventPoster 931 event_banner: str | None # strEventBanner 932 event_square: str | None # strEventSquare 933 934 @classmethod 935 def _read(cls, r: Rec) -> dict[str, Any]: 936 return dict(id=r.id_("id"), event_id=r.id_("idEvent"), event=r.s("strEvent"), sport=r.s("strSport"), 937 season=r.s("strSeason"), channel_id=r.id_("idChannel"), channel=r.s("strChannel"), 938 channel_logo=r.s("strLogo"), country=r.s("strCountry"), event_country=r.s("strEventCountry"), 939 timestamp=r.ts("strTimeStamp") or r.ts("strTimestamp"), date=r.d("dateEvent"), 940 time=r.t("strTime"), division=r.i("intDivision"), event_thumb=r.s("strEventThumb"), 941 event_poster=r.s("strEventPoster"), event_banner=r.s("strEventBanner"), 942 event_square=r.s("strEventSquare"))
One broadcast of an event on one channel.
633@dataclass(frozen=True, kw_only=True, eq=False, repr=False) 634class Venue(ApiRecord): 635 """A stadium, arena or circuit.""" 636 637 _summary = ("id", "name") 638 id: int | None # idVenue 639 name: str | None # strVenue 640 alternate_name: str | None # strVenueAlternate 641 sponsor_name: str | None # strVenueSponsor 642 sport: str | None # strSport 643 location: str | None # strLocation 644 country: str | None # strCountry 645 timezone: str | None # strTimezone, as text 646 capacity: int | None # intCapacity 647 formed_year: int | None # intFormedYear 648 architect: str | None # strArchitect 649 cost: str | None # strCost, as text 650 map: str | None # strMap: "lat, lon" or an image URL 651 thumb: str | None # strThumb 652 logo: str | None # strLogo 653 fanart: tuple[str, ...] # strFanart1..4 654 creative_commons: bool | None # strCreativeCommons 655 descriptions: Mapping[str, str] 656 socials: Socials 657 loved: int | None # intLoved 658 is_locked: bool | None # strLocked 659 duplicate_of: int | None # idDupe 660 661 @property 662 def description(self) -> str | None: 663 return self.descriptions.get("EN") 664 665 @property 666 def coordinates(self) -> tuple[float, float] | None: 667 """``map`` as (latitude, longitude), when it holds coordinates.""" 668 parts = (self.map or "").split(",") 669 if len(parts) != 2: 670 return None 671 try: 672 return float(parts[0]), float(parts[1]) 673 except ValueError: 674 return None 675 676 @classmethod 677 def _read(cls, r: Rec) -> dict[str, Any]: 678 return dict( 679 id=r.id_("idVenue"), name=r.s("strVenue"), alternate_name=r.s("strVenueAlternate"), 680 sponsor_name=r.s("strVenueSponsor"), sport=r.s("strSport"), location=r.s("strLocation"), 681 country=r.s("strCountry"), timezone=r.s("strTimezone"), capacity=r.i("intCapacity"), 682 formed_year=r.year("intFormedYear"), architect=r.s("strArchitect"), cost=r.s("strCost"), 683 map=r.s("strMap"), thumb=r.s("strThumb"), logo=r.s("strLogo"), fanart=r.numbered("strFanart", 4), 684 creative_commons=r.b("strCreativeCommons"), descriptions=MappingProxyType(r.descriptions()), 685 socials=Socials._read(r), loved=r.i("intLoved"), is_locked=r.locked(), duplicate_of=r.id_("idDupe"), 686 )
A stadium, arena or circuit.
665 @property 666 def coordinates(self) -> tuple[float, float] | None: 667 """``map`` as (latitude, longitude), when it holds coordinates.""" 668 parts = (self.map or "").split(",") 669 if len(parts) != 2: 670 return None 671 try: 672 return float(parts[0]), float(parts[1]) 673 except ValueError: 674 return None
map as (latitude, longitude), when it holds coordinates.
104def sized(url: str | None, size: ImageSize) -> str | None: 105 """The same image at a smaller size. 106 107 Only ``r2.thesportsdb.com`` and ``www.thesportsdb.com/images/media/`` images support the 108 suffixes (others return 404, verified 5 Oct 2026); other URLs and None are returned unchanged. 109 """ 110 if url is None or not url.startswith(("https://r2.thesportsdb.com/", "https://www.thesportsdb.com/images/media/")): 111 return url 112 for s in ImageSize: 113 url = url.removesuffix(s.value) 114 return url + size.value
The same image at a smaller size.
Only r2.thesportsdb.com and www.thesportsdb.com/images/media/ images support the
suffixes (others return 404, verified 5 Oct 2026); other URLs and None are returned unchanged.