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 +48 -0
- gameapi/_base.py +59 -0
- gameapi/async_client.py +40 -0
- gameapi/cache/__init__.py +5 -0
- gameapi/cache/memory.py +40 -0
- gameapi/client.py +43 -0
- gameapi/exceptions.py +95 -0
- gameapi/games/__init__.py +6 -0
- gameapi/games/base.py +60 -0
- gameapi/games/chess_com/__init__.py +5 -0
- gameapi/games/chess_com/client.py +271 -0
- gameapi/games/chess_com/endpoints.py +21 -0
- gameapi/games/chess_com/models.py +35 -0
- gameapi/games/lichess/__init__.py +5 -0
- gameapi/games/lichess/client.py +156 -0
- gameapi/games/lichess/endpoints.py +21 -0
- gameapi/games/lichess/models.py +41 -0
- gameapi/games/registry.py +25 -0
- gameapi/http/__init__.py +5 -0
- gameapi/http/client.py +203 -0
- gameapi/models/__init__.py +15 -0
- gameapi/models/leaderboard.py +34 -0
- gameapi/models/match.py +31 -0
- gameapi/models/player.py +27 -0
- gameapi/models/stats.py +27 -0
- universal_game_api-0.1.0.dist-info/METADATA +291 -0
- universal_game_api-0.1.0.dist-info/RECORD +29 -0
- universal_game_api-0.1.0.dist-info/WHEEL +4 -0
- universal_game_api-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
}
|
gameapi/async_client.py
ADDED
|
@@ -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})"
|
gameapi/cache/memory.py
ADDED
|
@@ -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)
|
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,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"
|