universal-game-api 0.1.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.
gameapi/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """Universal Game API — a unified, developer-friendly interface for public
2
+ game data and statistics.
3
+
4
+ Quick start:
5
+
6
+ >>> from gameapi import GameAPI
7
+ >>> api = GameAPI()
8
+ >>> player = api.player(game="chess_com", identifier="hikaru")
9
+ >>> print(player.name, player.stats)
10
+
11
+ See the README for the full guide, supported games, and async usage.
12
+ """
13
+
14
+ from .async_client import AsyncGameAPI
15
+ from .client import GameAPI
16
+ from .exceptions import (
17
+ APIUnavailableError,
18
+ AuthenticationError,
19
+ GameAPIError,
20
+ GameNotSupportedError,
21
+ InvalidResponseError,
22
+ PlayerNotFoundError,
23
+ RateLimitError,
24
+ )
25
+ from .games.registry import supported_games
26
+ from .models import Leaderboard, LeaderboardEntry, Match, Player, PlayerStats, Rank
27
+
28
+ __version__ = "0.2.0"
29
+
30
+ __all__ = [
31
+ "GameAPI",
32
+ "AsyncGameAPI",
33
+ "Player",
34
+ "PlayerStats",
35
+ "Rank",
36
+ "Match",
37
+ "Leaderboard",
38
+ "LeaderboardEntry",
39
+ "GameAPIError",
40
+ "GameNotSupportedError",
41
+ "PlayerNotFoundError",
42
+ "AuthenticationError",
43
+ "RateLimitError",
44
+ "APIUnavailableError",
45
+ "InvalidResponseError",
46
+ "supported_games",
47
+ "__version__",
48
+ ]
gameapi/_base.py ADDED
@@ -0,0 +1,59 @@
1
+ """Shared setup/resolution logic for GameAPI and AsyncGameAPI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Optional
7
+
8
+ from .cache import MemoryCache
9
+ from .exceptions import GameNotSupportedError
10
+ from .games.base import GameIntegration
11
+ from .games.registry import GAME_REGISTRY, supported_games
12
+ from .http import HTTPClient
13
+
14
+ _ENV_API_KEY = "GAMEAPI_API_KEY"
15
+
16
+
17
+ class _BaseGameAPI:
18
+ """Common configuration and integration resolution for both clients."""
19
+
20
+ def __init__(
21
+ self,
22
+ api_key: Optional[str] = None,
23
+ cache: bool = False,
24
+ cache_ttl: float = 60.0,
25
+ timeout: float = 10.0,
26
+ max_retries: int = 2,
27
+ ) -> None:
28
+ self.api_key = api_key or os.environ.get(_ENV_API_KEY)
29
+ self.cache_enabled = cache
30
+ self.cache_ttl = cache_ttl
31
+
32
+ self._http = HTTPClient(timeout=timeout, max_retries=max_retries)
33
+ self._cache = MemoryCache(default_ttl=cache_ttl) if cache else None
34
+ self._integrations: dict = {}
35
+
36
+ def _resolve(self, game: str) -> GameIntegration:
37
+ integration = self._integrations.get(game)
38
+ if integration is not None:
39
+ return integration
40
+
41
+ integration_cls = GAME_REGISTRY.get(game)
42
+ if integration_cls is None:
43
+ raise GameNotSupportedError(game, supported=supported_games())
44
+
45
+ integration = integration_cls(self._http, api_key=self.api_key, cache=self._cache)
46
+ self._integrations[game] = integration
47
+ return integration
48
+
49
+ def game_info(self, game: str) -> dict:
50
+ """Return metadata about a registered integration."""
51
+ integration_cls = GAME_REGISTRY.get(game)
52
+ if integration_cls is None:
53
+ raise GameNotSupportedError(game, supported=supported_games())
54
+ return {
55
+ "slug": integration_cls.slug,
56
+ "requires_api_key": integration_cls.requires_api_key,
57
+ "source_name": integration_cls.source_name,
58
+ "source_url": integration_cls.source_url,
59
+ }
@@ -0,0 +1,40 @@
1
+ """The asynchronous public entry point for gameapi."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List, Optional
6
+
7
+ from ._base import _BaseGameAPI
8
+ from .games.registry import supported_games
9
+ from .models import Leaderboard, Match, Player
10
+
11
+
12
+ class AsyncGameAPI(_BaseGameAPI):
13
+ """Asynchronous client for accessing unified public game data."""
14
+
15
+ async def player(self, game: str, identifier: str) -> Player:
16
+ return await self._resolve(game).get_player_async(identifier)
17
+
18
+ async def matches(self, game: str, identifier: str, limit: int = 20) -> List[Match]:
19
+ return await self._resolve(game).get_matches_async(identifier, limit=limit)
20
+
21
+ async def leaderboard(self, game: str, region: Optional[str] = None) -> Leaderboard:
22
+ return await self._resolve(game).get_leaderboard_async(region=region)
23
+
24
+ async def compare_players(self, game: str, identifiers: List[str]) -> List[Player]:
25
+ """Fetch multiple players concurrently."""
26
+ integration = self._resolve(game)
27
+ return [await integration.get_player_async(ident) for ident in identifiers]
28
+
29
+ async def aclose(self) -> None:
30
+ """Release the underlying HTTP connection pool."""
31
+ await self._http.aclose()
32
+
33
+ async def __aenter__(self) -> "AsyncGameAPI":
34
+ return self
35
+
36
+ async def __aexit__(self, *exc_info: object) -> None:
37
+ await self.aclose()
38
+
39
+ def __repr__(self) -> str:
40
+ return f"AsyncGameAPI(games={supported_games()}, cache={self.cache_enabled})"
@@ -0,0 +1,5 @@
1
+ """Caching layer for gameapi."""
2
+
3
+ from .memory import MemoryCache
4
+
5
+ __all__ = ["MemoryCache"]
@@ -0,0 +1,40 @@
1
+ """A minimal, dependency-free in-memory cache with per-entry TTL expiry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any, Dict, Optional, Tuple
7
+
8
+
9
+ class MemoryCache:
10
+ """A simple thread-unsafe, process-local TTL cache."""
11
+
12
+ def __init__(self, default_ttl: float = 60.0) -> None:
13
+ self.default_ttl = default_ttl
14
+ self._store: Dict[str, Tuple[float, Any]] = {}
15
+
16
+ def get(self, key: str) -> Optional[Any]:
17
+ """Return the cached value for ``key``, or ``None`` if missing/expired."""
18
+ entry = self._store.get(key)
19
+ if entry is None:
20
+ return None
21
+ expires_at, value = entry
22
+ if expires_at < time.monotonic():
23
+ del self._store[key]
24
+ return None
25
+ return value
26
+
27
+ def set(self, key: str, value: Any, ttl: Optional[float] = None) -> None:
28
+ """Store ``value`` under ``key`` for ``ttl`` seconds."""
29
+ effective_ttl = self.default_ttl if ttl is None else ttl
30
+ self._store[key] = (time.monotonic() + effective_ttl, value)
31
+
32
+ def clear(self) -> None:
33
+ """Remove all cached entries."""
34
+ self._store.clear()
35
+
36
+ def __contains__(self, key: str) -> bool:
37
+ return self.get(key) is not None
38
+
39
+ def __len__(self) -> int:
40
+ return len(self._store)
gameapi/client.py ADDED
@@ -0,0 +1,43 @@
1
+ """The synchronous public entry point for gameapi."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List, Optional
6
+
7
+ from ._base import _BaseGameAPI
8
+ from .games.registry import supported_games
9
+ from .models import Leaderboard, Match, Player
10
+
11
+
12
+ class GameAPI(_BaseGameAPI):
13
+ """Synchronous client for accessing unified public game data."""
14
+
15
+ def player(self, game: str, identifier: str) -> Player:
16
+ """Fetch a unified player profile."""
17
+ return self._resolve(game).get_player(identifier)
18
+
19
+ def matches(self, game: str, identifier: str, limit: int = 20) -> List[Match]:
20
+ """Fetch a player's recent matches, most recent first."""
21
+ return self._resolve(game).get_matches(identifier, limit=limit)
22
+
23
+ def leaderboard(self, game: str, region: Optional[str] = None) -> Leaderboard:
24
+ """Fetch a game's leaderboard."""
25
+ return self._resolve(game).get_leaderboard(region=region)
26
+
27
+ def compare_players(self, game: str, identifiers: List[str]) -> List[Player]:
28
+ """Fetch multiple players at once (synchronous)."""
29
+ integration = self._resolve(game)
30
+ return [integration.get_player(ident) for ident in identifiers]
31
+
32
+ def close(self) -> None:
33
+ """Release the underlying HTTP connection pool."""
34
+ self._http.close()
35
+
36
+ def __enter__(self) -> "GameAPI":
37
+ return self
38
+
39
+ def __exit__(self, *exc_info: object) -> None:
40
+ self.close()
41
+
42
+ def __repr__(self) -> str:
43
+ return f"GameAPI(games={supported_games()}, cache={self.cache_enabled})"
gameapi/exceptions.py ADDED
@@ -0,0 +1,95 @@
1
+ """Custom exception hierarchy for Universal Game API.
2
+
3
+ All exceptions raised by the public API inherit from :class:`GameAPIError`,
4
+ so callers can either catch broad failures or narrow in on a specific
5
+ failure mode.
6
+
7
+ Example
8
+ -------
9
+ >>> try:
10
+ ... player = api.player("chess_com", "does-not-exist-hopefully")
11
+ ... except PlayerNotFoundError:
12
+ ... print("Player doesn't exist.")
13
+ ... except GameAPIError as exc:
14
+ ... print(f"Something else went wrong: {exc}")
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Optional
20
+
21
+
22
+ class GameAPIError(Exception):
23
+ """Base class for all errors raised by gameapi.
24
+
25
+ Attributes:
26
+ message: Human-readable description of the failure.
27
+ """
28
+
29
+ def __init__(self, message: str) -> None:
30
+ self.message = message
31
+ super().__init__(message)
32
+
33
+
34
+ class GameNotSupportedError(GameAPIError):
35
+ """Raised when a requested game has no registered integration."""
36
+
37
+ def __init__(self, game: str, supported: Optional[list] = None) -> None:
38
+ self.game = game
39
+ self.supported = supported or []
40
+ supported_str = ", ".join(sorted(self.supported)) if self.supported else "none registered"
41
+ super().__init__(
42
+ f"Game '{game}' is not supported. Currently supported games: {supported_str}."
43
+ )
44
+
45
+
46
+ class PlayerNotFoundError(GameAPIError):
47
+ """Raised when a game's API reports that a player/identifier does not exist."""
48
+
49
+ def __init__(self, game: str, identifier: str) -> None:
50
+ self.game = game
51
+ self.identifier = identifier
52
+ super().__init__(f"Player '{identifier}' was not found for game '{game}'.")
53
+
54
+
55
+ class AuthenticationError(GameAPIError):
56
+ """Raised when a request fails due to missing or invalid credentials."""
57
+
58
+ def __init__(self, message: str = "Authentication failed. Check your API key.") -> None:
59
+ super().__init__(message)
60
+
61
+
62
+ class RateLimitError(GameAPIError):
63
+ """Raised when the upstream API reports that a rate limit was exceeded.
64
+
65
+ Attributes:
66
+ retry_after: Seconds to wait before retrying, if the upstream API
67
+ provided this information (via a ``Retry-After`` header). May be
68
+ ``None`` if the upstream response did not include it.
69
+ """
70
+
71
+ def __init__(
72
+ self, message: str = "Rate limit exceeded.", retry_after: Optional[float] = None
73
+ ) -> None:
74
+ self.retry_after = retry_after
75
+ super().__init__(message)
76
+
77
+ def __repr__(self) -> str:
78
+ return f"RateLimitError(retry_after={self.retry_after!r}, message={self.message!r})"
79
+
80
+
81
+ class APIUnavailableError(GameAPIError):
82
+ """Raised when the upstream API is unreachable or returns a server error
83
+ after retries have been exhausted."""
84
+
85
+ def __init__(self, message: str = "The upstream API is currently unavailable.") -> None:
86
+ super().__init__(message)
87
+
88
+
89
+ class InvalidResponseError(GameAPIError):
90
+ """Raised when the upstream API returns a response gameapi cannot parse."""
91
+
92
+ def __init__(
93
+ self, message: str = "Received an invalid or unparsable response from the API."
94
+ ) -> None:
95
+ super().__init__(message)
@@ -0,0 +1,6 @@
1
+ """Game-specific integrations."""
2
+
3
+ from .base import GameIntegration
4
+ from .registry import GAME_REGISTRY, register_game, supported_games
5
+
6
+ __all__ = ["GameIntegration", "GAME_REGISTRY", "register_game", "supported_games"]
gameapi/games/base.py ADDED
@@ -0,0 +1,60 @@
1
+ """Abstract base class every game integration must implement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import List, Optional
7
+
8
+ from ..cache import MemoryCache
9
+ from ..http import HTTPClient
10
+ from ..models import Leaderboard, Match, Player
11
+
12
+
13
+ class GameIntegration(ABC):
14
+ """Base class for a single game's integration."""
15
+
16
+ slug: str
17
+ requires_api_key: bool = False
18
+ source_name: str = "unknown"
19
+ source_url: str = ""
20
+
21
+ def __init__(
22
+ self,
23
+ http: HTTPClient,
24
+ *,
25
+ api_key: Optional[str] = None,
26
+ cache: Optional[MemoryCache] = None,
27
+ ) -> None:
28
+ self.http = http
29
+ self.api_key = api_key
30
+ self.cache = cache
31
+
32
+ @abstractmethod
33
+ def get_player(self, identifier: str) -> Player:
34
+ """Fetch a player profile synchronously."""
35
+
36
+ @abstractmethod
37
+ async def get_player_async(self, identifier: str) -> Player:
38
+ """Fetch a player profile asynchronously."""
39
+
40
+ def get_matches(self, identifier: str, limit: int = 20) -> List[Match]:
41
+ raise NotImplementedError(f"'{self.slug}' does not support match history.")
42
+
43
+ async def get_matches_async(self, identifier: str, limit: int = 20) -> List[Match]:
44
+ raise NotImplementedError(f"'{self.slug}' does not support match history.")
45
+
46
+ def get_leaderboard(self, region: Optional[str] = None) -> Leaderboard:
47
+ raise NotImplementedError(f"'{self.slug}' does not support leaderboards.")
48
+
49
+ async def get_leaderboard_async(self, region: Optional[str] = None) -> Leaderboard:
50
+ raise NotImplementedError(f"'{self.slug}' does not support leaderboards.")
51
+
52
+ def _cache_get(self, key: str) -> Optional[object]:
53
+ if self.cache is None:
54
+ return None
55
+ return self.cache.get(f"{self.slug}:{key}")
56
+
57
+ def _cache_set(self, key: str, value: object, ttl: Optional[float] = None) -> None:
58
+ if self.cache is None:
59
+ return
60
+ self.cache.set(f"{self.slug}:{key}", value, ttl=ttl)
@@ -0,0 +1,5 @@
1
+ """Chess.com game integration."""
2
+
3
+ from .client import ChessComIntegration
4
+
5
+ __all__ = ["ChessComIntegration"]
@@ -0,0 +1,271 @@
1
+ """Chess.com game integration.
2
+
3
+ Data source: the free, public Chess.com "Published-Data API"
4
+ (https://www.chess.com/news/view/published-data-api). No API key is
5
+ required. This integration is not affiliated with, endorsed by, or
6
+ sponsored by Chess.com.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import datetime, timezone
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ from ...exceptions import InvalidResponseError, PlayerNotFoundError
15
+ from ...models import Leaderboard, LeaderboardEntry, Match, Player, PlayerStats, Rank
16
+ from ..base import GameIntegration
17
+ from . import endpoints
18
+ from .models import ChessComMatchData, ChessComPlayerData, ChessComRatingSummary
19
+
20
+ _USER_AGENT = "gameapi/0.2.0 (+https://github.com/F0xyN0xy/universal-game-api)"
21
+
22
+ _TIME_CONTROL_KEYS = {
23
+ "chess_bullet": "bullet",
24
+ "chess_blitz": "blitz",
25
+ "chess_rapid": "rapid",
26
+ "chess_daily": "daily",
27
+ }
28
+
29
+ _PRIMARY_TIME_CONTROL = "chess_rapid"
30
+ _DEFAULT_LEADERBOARD_CATEGORY = "live_blitz"
31
+
32
+
33
+ class ChessComIntegration(GameIntegration):
34
+ """Game integration for Chess.com."""
35
+
36
+ slug = "chess_com"
37
+ requires_api_key = False
38
+ source_name = "Chess.com Published-Data API"
39
+ source_url = "https://www.chess.com/news/view/published-data-api"
40
+
41
+ def _headers(self) -> Dict[str, str]:
42
+ return {"User-Agent": _USER_AGENT, "Accept": "application/json"}
43
+
44
+ def get_player(self, identifier: str) -> Player:
45
+ cached = self._cache_get(f"player:{identifier}")
46
+ if cached is not None:
47
+ return cached # type: ignore[return-value]
48
+
49
+ profile = self._fetch_profile(identifier)
50
+ stats = self._fetch_stats(identifier)
51
+ player = self._build_player(identifier, profile, stats)
52
+ self._cache_set(f"player:{identifier}", player, ttl=60)
53
+ return player
54
+
55
+ async def get_player_async(self, identifier: str) -> Player:
56
+ cached = self._cache_get(f"player:{identifier}")
57
+ if cached is not None:
58
+ return cached # type: ignore[return-value]
59
+
60
+ profile = await self._fetch_profile_async(identifier)
61
+ stats = await self._fetch_stats_async(identifier)
62
+ player = self._build_player(identifier, profile, stats)
63
+ self._cache_set(f"player:{identifier}", player, ttl=60)
64
+ return player
65
+
66
+ def _fetch_profile(self, identifier: str) -> Dict[str, Any]:
67
+ try:
68
+ return self.http.request(
69
+ "GET", endpoints.player_profile_url(identifier), headers=self._headers()
70
+ )
71
+ except InvalidResponseError as exc:
72
+ raise PlayerNotFoundError(self.slug, identifier) from exc
73
+
74
+ async def _fetch_profile_async(self, identifier: str) -> Dict[str, Any]:
75
+ try:
76
+ return await self.http.request_async(
77
+ "GET", endpoints.player_profile_url(identifier), headers=self._headers()
78
+ )
79
+ except InvalidResponseError as exc:
80
+ raise PlayerNotFoundError(self.slug, identifier) from exc
81
+
82
+ def _fetch_stats(self, identifier: str) -> Dict[str, Any]:
83
+ try:
84
+ return self.http.request(
85
+ "GET", endpoints.player_stats_url(identifier), headers=self._headers()
86
+ )
87
+ except InvalidResponseError:
88
+ return {}
89
+
90
+ async def _fetch_stats_async(self, identifier: str) -> Dict[str, Any]:
91
+ try:
92
+ return await self.http.request_async(
93
+ "GET", endpoints.player_stats_url(identifier), headers=self._headers()
94
+ )
95
+ except InvalidResponseError:
96
+ return {}
97
+
98
+ def _build_player(
99
+ self, identifier: str, profile: Dict[str, Any], stats: Dict[str, Any]
100
+ ) -> Player:
101
+ ratings: Dict[str, ChessComRatingSummary] = {}
102
+ total_wins = total_losses = total_draws = 0
103
+ has_record = False
104
+
105
+ for stats_key, label in _TIME_CONTROL_KEYS.items():
106
+ block = stats.get(stats_key)
107
+ if not block:
108
+ continue
109
+ last = block.get("last", {})
110
+ record = block.get("record", {})
111
+ summary = ChessComRatingSummary(
112
+ rating=last.get("rating"),
113
+ wins=record.get("win"),
114
+ losses=record.get("loss"),
115
+ draws=record.get("draw"),
116
+ )
117
+ ratings[label] = summary
118
+ if record:
119
+ has_record = True
120
+ total_wins += record.get("win", 0) or 0
121
+ total_losses += record.get("loss", 0) or 0
122
+ total_draws += record.get("draw", 0) or 0
123
+
124
+ games_played = total_wins + total_losses + total_draws if has_record else None
125
+ win_rate = (total_wins / games_played) if games_played else None
126
+
127
+ player_stats = PlayerStats(
128
+ games_played=games_played,
129
+ wins=total_wins if has_record else None,
130
+ losses=total_losses if has_record else None,
131
+ draws=total_draws if has_record else None,
132
+ win_rate=win_rate,
133
+ )
134
+
135
+ primary = ratings.get(_TIME_CONTROL_KEYS[_PRIMARY_TIME_CONTROL])
136
+ rank = Rank(
137
+ tier=profile.get("title"),
138
+ rating=primary.rating if primary else None,
139
+ raw={"tactics": stats.get("tactics"), "puzzle_rush": stats.get("puzzle_rush")},
140
+ )
141
+
142
+ game_data = ChessComPlayerData(
143
+ title=profile.get("title"),
144
+ country=profile.get("country"),
145
+ followers=profile.get("followers"),
146
+ joined_timestamp=profile.get("joined"),
147
+ league=profile.get("league"),
148
+ ratings=ratings,
149
+ puzzle_rush_best=(stats.get("puzzle_rush", {}) or {})
150
+ .get("best", {})
151
+ .get("score"),
152
+ )
153
+
154
+ return Player(
155
+ name=profile.get("username", identifier),
156
+ game=self.slug,
157
+ identifier=identifier,
158
+ stats=player_stats,
159
+ rank=rank,
160
+ game_data=game_data,
161
+ avatar_url=profile.get("avatar"),
162
+ )
163
+
164
+ def get_matches(self, identifier: str, limit: int = 20) -> List[Match]:
165
+ archives = self._fetch_archives(identifier)
166
+ games: List[Dict[str, Any]] = []
167
+ for archive_url in reversed(archives):
168
+ if len(games) >= limit:
169
+ break
170
+ payload = self.http.request("GET", archive_url, headers=self._headers())
171
+ games.extend(payload.get("games", []))
172
+ return self._build_matches(identifier, games, limit)
173
+
174
+ async def get_matches_async(self, identifier: str, limit: int = 20) -> List[Match]:
175
+ archives = await self._fetch_archives_async(identifier)
176
+ games: List[Dict[str, Any]] = []
177
+ for archive_url in reversed(archives):
178
+ if len(games) >= limit:
179
+ break
180
+ payload = await self.http.request_async("GET", archive_url, headers=self._headers())
181
+ games.extend(payload.get("games", []))
182
+ return self._build_matches(identifier, games, limit)
183
+
184
+ def _fetch_archives(self, identifier: str) -> List[str]:
185
+ try:
186
+ payload = self.http.request(
187
+ "GET", endpoints.player_archives_url(identifier), headers=self._headers()
188
+ )
189
+ except InvalidResponseError as exc:
190
+ raise PlayerNotFoundError(self.slug, identifier) from exc
191
+ return payload.get("archives", [])
192
+
193
+ async def _fetch_archives_async(self, identifier: str) -> List[str]:
194
+ try:
195
+ payload = await self.http.request_async(
196
+ "GET", endpoints.player_archives_url(identifier), headers=self._headers()
197
+ )
198
+ except InvalidResponseError as exc:
199
+ raise PlayerNotFoundError(self.slug, identifier) from exc
200
+ return payload.get("archives", [])
201
+
202
+ def _build_matches(
203
+ self, identifier: str, games: List[Dict[str, Any]], limit: int
204
+ ) -> List[Match]:
205
+ matches: List[Match] = []
206
+ lowered = identifier.lower()
207
+ for raw in reversed(games):
208
+ if len(matches) >= limit:
209
+ break
210
+ white = raw.get("white", {})
211
+ black = raw.get("black", {})
212
+ is_white = white.get("username", "").lower() == lowered
213
+ mine, opponent = (white, black) if is_white else (black, white)
214
+
215
+ result = _normalize_result(mine.get("result"))
216
+ played_at = None
217
+ if raw.get("end_time"):
218
+ played_at = datetime.fromtimestamp(raw["end_time"], tz=timezone.utc)
219
+
220
+ matches.append(
221
+ Match(
222
+ id=raw.get("url", ""),
223
+ game=self.slug,
224
+ played_at=played_at,
225
+ result=result,
226
+ opponent=opponent.get("username"),
227
+ game_data=ChessComMatchData(
228
+ time_class=raw.get("time_class"),
229
+ white=white.get("username"),
230
+ black=black.get("username"),
231
+ white_result=white.get("result"),
232
+ black_result=black.get("result"),
233
+ pgn_url=raw.get("url"),
234
+ ),
235
+ )
236
+ )
237
+ return matches
238
+
239
+ def get_leaderboard(self, region: Optional[str] = None) -> Leaderboard:
240
+ payload = self.http.request("GET", endpoints.leaderboards_url(), headers=self._headers())
241
+ return self._build_leaderboard(payload, region)
242
+
243
+ async def get_leaderboard_async(self, region: Optional[str] = None) -> Leaderboard:
244
+ payload = await self.http.request_async(
245
+ "GET", endpoints.leaderboards_url(), headers=self._headers()
246
+ )
247
+ return self._build_leaderboard(payload, region)
248
+
249
+ def _build_leaderboard(self, payload: Dict[str, Any], region: Optional[str]) -> Leaderboard:
250
+ rows = payload.get(_DEFAULT_LEADERBOARD_CATEGORY, [])
251
+ entries = [
252
+ LeaderboardEntry(
253
+ position=row.get("rank", idx + 1),
254
+ name=row.get("username", "unknown"),
255
+ rating=row.get("score"),
256
+ )
257
+ for idx, row in enumerate(rows)
258
+ ]
259
+ return Leaderboard(game=self.slug, entries=entries, region=region)
260
+
261
+
262
+ def _normalize_result(chess_com_result: Optional[str]) -> str:
263
+ if chess_com_result is None:
264
+ return "unknown"
265
+ if chess_com_result == "win":
266
+ return "win"
267
+ if chess_com_result in {
268
+ "agreed", "repetition", "stalemate", "insufficient", "50move", "timevsinsufficient"
269
+ }:
270
+ return "draw"
271
+ return "loss"