copsapi-wrapper 1.0.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.
- copsapi/__init__.py +203 -0
- copsapi/__main__.py +3 -0
- copsapi/_async_client.py +630 -0
- copsapi/_core.py +216 -0
- copsapi/_io_async.py +50 -0
- copsapi/_io_sync.py +26 -0
- copsapi/_sync_client.py +630 -0
- copsapi/cache.py +73 -0
- copsapi/catalog.py +158 -0
- copsapi/cli.py +242 -0
- copsapi/errors.py +129 -0
- copsapi/helpers.py +175 -0
- copsapi/models.py +365 -0
- copsapi/py.typed +0 -0
- copsapi/quota.py +97 -0
- copsapi/watcher.py +346 -0
- copsapi_wrapper-1.0.0.dist-info/METADATA +581 -0
- copsapi_wrapper-1.0.0.dist-info/RECORD +21 -0
- copsapi_wrapper-1.0.0.dist-info/WHEEL +4 -0
- copsapi_wrapper-1.0.0.dist-info/entry_points.txt +2 -0
- copsapi_wrapper-1.0.0.dist-info/licenses/LICENSE +21 -0
copsapi/_async_client.py
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import builtins
|
|
4
|
+
import random
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, AsyncIterator, Awaitable, Callable, Optional, Sequence, Union
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._core import (
|
|
11
|
+
AUTOHOST_DENIED,
|
|
12
|
+
VERSION,
|
|
13
|
+
PlayerRef,
|
|
14
|
+
api_error,
|
|
15
|
+
build_query,
|
|
16
|
+
check_command,
|
|
17
|
+
check_match_ids,
|
|
18
|
+
check_rank,
|
|
19
|
+
chunks,
|
|
20
|
+
create_body,
|
|
21
|
+
dedupe_refs,
|
|
22
|
+
game_type_code,
|
|
23
|
+
match_refs,
|
|
24
|
+
order_profiles,
|
|
25
|
+
parse_json,
|
|
26
|
+
ref_key,
|
|
27
|
+
resolve_base_url,
|
|
28
|
+
resolve_token,
|
|
29
|
+
split_refs,
|
|
30
|
+
)
|
|
31
|
+
from ._io_async import Gate, HttpClient, resolve, sleep
|
|
32
|
+
from .cache import CacheStore, MemoryCache, resolve_ttls
|
|
33
|
+
from .errors import CopsApiError, make_error
|
|
34
|
+
from .helpers import random_password, region_of
|
|
35
|
+
from .models import (
|
|
36
|
+
AutohostGame,
|
|
37
|
+
GameServer,
|
|
38
|
+
HostedRoom,
|
|
39
|
+
LeaderboardEntry,
|
|
40
|
+
LeaderboardPage,
|
|
41
|
+
MatchHistoryEntry,
|
|
42
|
+
MatchInfo,
|
|
43
|
+
Profile,
|
|
44
|
+
QuotaSnapshot,
|
|
45
|
+
TokenCheck,
|
|
46
|
+
WeaponStats,
|
|
47
|
+
from_dict,
|
|
48
|
+
)
|
|
49
|
+
from .quota import QuotaTracker
|
|
50
|
+
from .watcher import AsyncAutohostWatcher
|
|
51
|
+
|
|
52
|
+
Hook = Optional[Callable[[dict[str, Any]], Any]]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AsyncTransport:
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
token: str,
|
|
60
|
+
base_url: str,
|
|
61
|
+
retries: int,
|
|
62
|
+
gateway_retries: int,
|
|
63
|
+
max_rate_limit_wait: float,
|
|
64
|
+
auth_header: str,
|
|
65
|
+
user_agent: Optional[str],
|
|
66
|
+
quota: QuotaTracker,
|
|
67
|
+
gate: Gate,
|
|
68
|
+
http: Any,
|
|
69
|
+
on_request: Hook,
|
|
70
|
+
on_response: Hook,
|
|
71
|
+
on_retry: Hook,
|
|
72
|
+
) -> None:
|
|
73
|
+
self._token = token
|
|
74
|
+
self._base_url = base_url
|
|
75
|
+
self._retries = retries
|
|
76
|
+
self._gateway_retries = gateway_retries
|
|
77
|
+
self._max_rate_limit_wait = max_rate_limit_wait
|
|
78
|
+
self._auth_header = auth_header
|
|
79
|
+
self._user_agent = user_agent
|
|
80
|
+
self._quota = quota
|
|
81
|
+
self._gate = gate
|
|
82
|
+
self._http = http
|
|
83
|
+
self._on_request = on_request
|
|
84
|
+
self._on_response = on_response
|
|
85
|
+
self._on_retry = on_retry
|
|
86
|
+
|
|
87
|
+
def _headers(self, has_body: bool, authenticated: bool) -> dict[str, str]:
|
|
88
|
+
headers = {"Accept": "application/json"}
|
|
89
|
+
if has_body:
|
|
90
|
+
headers["Content-Type"] = "application/json"
|
|
91
|
+
if self._user_agent:
|
|
92
|
+
headers["User-Agent"] = self._user_agent
|
|
93
|
+
if authenticated:
|
|
94
|
+
if self._auth_header == "x-api-key":
|
|
95
|
+
headers["X-Api-Key"] = self._token
|
|
96
|
+
else:
|
|
97
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
98
|
+
return headers
|
|
99
|
+
|
|
100
|
+
async def request(
|
|
101
|
+
self,
|
|
102
|
+
path: str,
|
|
103
|
+
*,
|
|
104
|
+
method: str = "GET",
|
|
105
|
+
query: Optional[dict[str, Any]] = None,
|
|
106
|
+
body: Any = None,
|
|
107
|
+
priority: str = "normal",
|
|
108
|
+
authenticated: bool = True,
|
|
109
|
+
) -> Any:
|
|
110
|
+
params = build_query(query)
|
|
111
|
+
key = f"{priority} {path}?{sorted(params.items())}" if method == "GET" else None
|
|
112
|
+
|
|
113
|
+
async def send() -> Any:
|
|
114
|
+
return await self._send(path, method, params, body, priority, authenticated)
|
|
115
|
+
|
|
116
|
+
return await self._gate.run(key, send)
|
|
117
|
+
|
|
118
|
+
async def _retry(self, info: dict[str, Any], delay: float) -> None:
|
|
119
|
+
if self._on_retry:
|
|
120
|
+
self._on_retry(info)
|
|
121
|
+
await sleep(delay)
|
|
122
|
+
|
|
123
|
+
async def _send(self, path: str, method: str, params: dict[str, str], body: Any, priority: str, authenticated: bool) -> Any:
|
|
124
|
+
idempotent = method == "GET"
|
|
125
|
+
headers = self._headers(body is not None, authenticated)
|
|
126
|
+
url = self._base_url + path
|
|
127
|
+
attempt = network_retries = gateway_retries = server_retries = rate_waits = 0
|
|
128
|
+
while True:
|
|
129
|
+
attempt += 1
|
|
130
|
+
if authenticated:
|
|
131
|
+
while True:
|
|
132
|
+
wait = self._quota.try_acquire(priority)
|
|
133
|
+
if wait <= 0:
|
|
134
|
+
break
|
|
135
|
+
await sleep(wait)
|
|
136
|
+
if self._on_request:
|
|
137
|
+
self._on_request({"method": method, "path": path, "attempt": attempt})
|
|
138
|
+
started = time.monotonic()
|
|
139
|
+
try:
|
|
140
|
+
response = await self._http.request(method, url, params=params, json=body, headers=headers)
|
|
141
|
+
except httpx.TimeoutException as err:
|
|
142
|
+
if authenticated:
|
|
143
|
+
self._quota.release()
|
|
144
|
+
error = make_error("timeout", "No answer from the Critical Ops API in time.", method=method, path=path)
|
|
145
|
+
if idempotent and network_retries < self._retries:
|
|
146
|
+
network_retries += 1
|
|
147
|
+
await self._retry({"method": method, "path": path, "attempt": attempt, "reason": "timeout", "error": error}, 0.25 * 2 ** (network_retries - 1))
|
|
148
|
+
continue
|
|
149
|
+
raise error from err
|
|
150
|
+
except httpx.TransportError as err:
|
|
151
|
+
if authenticated:
|
|
152
|
+
self._quota.release()
|
|
153
|
+
error = make_error("network", f"Critical Ops API unreachable: {err}", method=method, path=path)
|
|
154
|
+
if idempotent and network_retries < self._retries:
|
|
155
|
+
network_retries += 1
|
|
156
|
+
await self._retry({"method": method, "path": path, "attempt": attempt, "reason": "network", "error": error}, 0.25 * 2 ** (network_retries - 1))
|
|
157
|
+
continue
|
|
158
|
+
raise error from err
|
|
159
|
+
|
|
160
|
+
if authenticated:
|
|
161
|
+
self._quota.update(response.headers)
|
|
162
|
+
self._quota.release()
|
|
163
|
+
status = response.status_code
|
|
164
|
+
if self._on_response:
|
|
165
|
+
self._on_response({"method": method, "path": path, "attempt": attempt, "status": status, "duration": time.monotonic() - started})
|
|
166
|
+
text = response.text
|
|
167
|
+
data = parse_json(text)
|
|
168
|
+
|
|
169
|
+
if 200 <= status < 300:
|
|
170
|
+
if data is None:
|
|
171
|
+
raise make_error("invalid_response", f"Expected JSON from {path}, got {text[:60] or 'an empty body'}.", status, method=method, path=path)
|
|
172
|
+
return data
|
|
173
|
+
|
|
174
|
+
has_api_error = isinstance(data, dict) and isinstance(data.get("error"), dict)
|
|
175
|
+
if not has_api_error and (status == 404 or status >= 500):
|
|
176
|
+
error = make_error("gateway", f"Critical Ops API gateway answered {status} {text[:40].strip()}".strip(), status, method=method, path=path)
|
|
177
|
+
if gateway_retries < self._gateway_retries:
|
|
178
|
+
gateway_retries += 1
|
|
179
|
+
await self._retry({"method": method, "path": path, "attempt": attempt, "reason": "gateway", "error": error}, 0.15 * gateway_retries)
|
|
180
|
+
continue
|
|
181
|
+
raise error
|
|
182
|
+
|
|
183
|
+
error = api_error(status, data, response.headers, method, path)
|
|
184
|
+
if error.code == "rate_limited":
|
|
185
|
+
self._quota.exhaust(error.retry_after)
|
|
186
|
+
wait = error.retry_after if error.retry_after is not None else self._quota.seconds_until_reset()
|
|
187
|
+
if rate_waits < 2 and wait <= self._max_rate_limit_wait:
|
|
188
|
+
rate_waits += 1
|
|
189
|
+
await self._retry({"method": method, "path": path, "attempt": attempt, "reason": "rate_limited", "error": error}, wait)
|
|
190
|
+
continue
|
|
191
|
+
raise error
|
|
192
|
+
|
|
193
|
+
if idempotent and error.code in ("internal_error", "server_unavailable") and server_retries < self._retries:
|
|
194
|
+
server_retries += 1
|
|
195
|
+
await self._retry({"method": method, "path": path, "attempt": attempt, "reason": "server_error", "error": error}, 0.5 * 2 ** (server_retries - 1))
|
|
196
|
+
continue
|
|
197
|
+
raise error
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class AsyncCopsClient:
|
|
201
|
+
def __init__(
|
|
202
|
+
self,
|
|
203
|
+
token: Optional[str] = None,
|
|
204
|
+
*,
|
|
205
|
+
base_url: Optional[str] = None,
|
|
206
|
+
timeout: float = 10.0,
|
|
207
|
+
retries: int = 2,
|
|
208
|
+
gateway_retries: int = 5,
|
|
209
|
+
max_rate_limit_wait: float = 15.0,
|
|
210
|
+
rate_limit: int = 60,
|
|
211
|
+
rate_limit_reserve: int = 0,
|
|
212
|
+
on_quota_exhausted: str = "wait",
|
|
213
|
+
max_quota_wait: float = 65.0,
|
|
214
|
+
max_concurrent: int = 8,
|
|
215
|
+
dedupe: bool = True,
|
|
216
|
+
cache: Union[bool, CacheStore] = True,
|
|
217
|
+
cache_ttls: Optional[dict[str, float]] = None,
|
|
218
|
+
auth_header: str = "bearer",
|
|
219
|
+
user_agent: Optional[str] = f"copsapi-wrapper-python/{VERSION}",
|
|
220
|
+
max_players_per_request: int = 20,
|
|
221
|
+
validate_token: bool = True,
|
|
222
|
+
http_client: Optional[Any] = None,
|
|
223
|
+
transport: Optional[Any] = None,
|
|
224
|
+
on_request: Hook = None,
|
|
225
|
+
on_response: Hook = None,
|
|
226
|
+
on_retry: Hook = None,
|
|
227
|
+
) -> None:
|
|
228
|
+
if auth_header not in ("bearer", "x-api-key"):
|
|
229
|
+
raise ValueError("auth_header must be 'bearer' or 'x-api-key'")
|
|
230
|
+
resolved = resolve_token(token, validate_token)
|
|
231
|
+
self.base_url = resolve_base_url(base_url)
|
|
232
|
+
self.max_players_per_request = max(1, max_players_per_request)
|
|
233
|
+
self._quota = QuotaTracker(rate_limit, rate_limit_reserve, on_quota_exhausted, max_quota_wait)
|
|
234
|
+
self._owns_http = http_client is None
|
|
235
|
+
self._http = http_client if http_client is not None else HttpClient(timeout=timeout, transport=transport)
|
|
236
|
+
self._transport = AsyncTransport(
|
|
237
|
+
token=resolved,
|
|
238
|
+
base_url=self.base_url,
|
|
239
|
+
retries=retries,
|
|
240
|
+
gateway_retries=gateway_retries,
|
|
241
|
+
max_rate_limit_wait=max_rate_limit_wait,
|
|
242
|
+
auth_header=auth_header,
|
|
243
|
+
user_agent=user_agent,
|
|
244
|
+
quota=self._quota,
|
|
245
|
+
gate=Gate(max_concurrent, dedupe),
|
|
246
|
+
http=self._http,
|
|
247
|
+
on_request=on_request,
|
|
248
|
+
on_response=on_response,
|
|
249
|
+
on_retry=on_retry,
|
|
250
|
+
)
|
|
251
|
+
self._store: Optional[CacheStore] = None if cache is False else (MemoryCache() if cache is True else cache)
|
|
252
|
+
self._ttls = resolve_ttls(cache_ttls)
|
|
253
|
+
self._autohost_allowed: Optional[bool] = None
|
|
254
|
+
self.profiles = AsyncProfilesApi(self)
|
|
255
|
+
self.servers = AsyncServersApi(self)
|
|
256
|
+
self.matches = AsyncMatchesApi(self)
|
|
257
|
+
self.stats = AsyncStatsApi(self)
|
|
258
|
+
self.leaderboards = AsyncLeaderboardsApi(self)
|
|
259
|
+
self.autohost = AsyncAutohostApi(self)
|
|
260
|
+
|
|
261
|
+
def __repr__(self) -> str:
|
|
262
|
+
return f"{type(self).__name__}(base_url={self.base_url!r})"
|
|
263
|
+
|
|
264
|
+
async def __aenter__(self) -> AsyncCopsClient:
|
|
265
|
+
return self
|
|
266
|
+
|
|
267
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
268
|
+
await self.aclose()
|
|
269
|
+
|
|
270
|
+
async def aclose(self) -> None:
|
|
271
|
+
if self._owns_http:
|
|
272
|
+
await self._http.aclose()
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def quota(self) -> QuotaSnapshot:
|
|
276
|
+
return self._quota.snapshot()
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def autohost_permission(self) -> Optional[bool]:
|
|
280
|
+
return self._autohost_allowed
|
|
281
|
+
|
|
282
|
+
def set_autohost_permission(self, allowed: Optional[bool]) -> None:
|
|
283
|
+
self._autohost_allowed = allowed
|
|
284
|
+
|
|
285
|
+
async def request(self, path: str, **kwargs: Any) -> Any:
|
|
286
|
+
return await self._transport.request(path, **kwargs)
|
|
287
|
+
|
|
288
|
+
async def open_api_spec(self) -> dict[str, Any]:
|
|
289
|
+
return await self._transport.request("/openapi.json", authenticated=False)
|
|
290
|
+
|
|
291
|
+
async def clear_cache(self) -> None:
|
|
292
|
+
if self._store is not None and hasattr(self._store, "clear"):
|
|
293
|
+
await resolve(self._store.clear())
|
|
294
|
+
|
|
295
|
+
async def verify_token(self) -> TokenCheck:
|
|
296
|
+
try:
|
|
297
|
+
await self.servers.list(fresh=True)
|
|
298
|
+
except CopsApiError as err:
|
|
299
|
+
if err.code in ("missing_token", "invalid_token", "token_revoked"):
|
|
300
|
+
return TokenCheck(False, False, err, self.quota)
|
|
301
|
+
raise
|
|
302
|
+
autohost = await self.autohost.is_available(fresh=True)
|
|
303
|
+
return TokenCheck(True, autohost, None, self.quota)
|
|
304
|
+
|
|
305
|
+
async def _cache_get(self, key: str) -> Any:
|
|
306
|
+
if self._store is None:
|
|
307
|
+
return None
|
|
308
|
+
return await resolve(self._store.get(key))
|
|
309
|
+
|
|
310
|
+
async def _cache_set(self, key: str, value: Any, kind: str) -> None:
|
|
311
|
+
if self._store is not None:
|
|
312
|
+
await resolve(self._store.set(key, value, self._ttls[kind]))
|
|
313
|
+
|
|
314
|
+
async def _cached(self, key: str, kind: str, fresh: bool, loader: Callable[[], Awaitable[Any]]) -> Any:
|
|
315
|
+
if not fresh:
|
|
316
|
+
hit = await self._cache_get(key)
|
|
317
|
+
if hit is not None:
|
|
318
|
+
return hit
|
|
319
|
+
value = await loader()
|
|
320
|
+
await self._cache_set(key, value, kind)
|
|
321
|
+
return value
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class AsyncProfilesApi:
|
|
325
|
+
def __init__(self, client: AsyncCopsClient) -> None:
|
|
326
|
+
self._client = client
|
|
327
|
+
|
|
328
|
+
async def get(self, player: PlayerRef, *, fresh: bool = False) -> Profile:
|
|
329
|
+
found = await self.many([player], fresh=fresh, skip_missing=False)
|
|
330
|
+
if not found:
|
|
331
|
+
raise make_error("user_not_found", f"No Critical Ops player {player!r}.", 404, path="/profile", method="GET")
|
|
332
|
+
return found[0]
|
|
333
|
+
|
|
334
|
+
async def find(self, player: PlayerRef, *, fresh: bool = False) -> Optional[Profile]:
|
|
335
|
+
found = await self.many([player], fresh=fresh)
|
|
336
|
+
return found[0] if found else None
|
|
337
|
+
|
|
338
|
+
async def many(self, players: Sequence[PlayerRef], *, fresh: bool = False, skip_missing: bool = True) -> list[Profile]:
|
|
339
|
+
refs = dedupe_refs(players)
|
|
340
|
+
found: dict[str, Profile] = {}
|
|
341
|
+
missing: list[PlayerRef] = []
|
|
342
|
+
for ref in refs:
|
|
343
|
+
hit = None if fresh else await self._client._cache_get(ref_key(ref))
|
|
344
|
+
if hit is not None:
|
|
345
|
+
found[ref_key(ref)] = hit
|
|
346
|
+
else:
|
|
347
|
+
missing.append(ref)
|
|
348
|
+
for group in chunks(missing, self._client.max_players_per_request):
|
|
349
|
+
for ref, profile in await self._load(group, skip_missing):
|
|
350
|
+
found[ref_key(ref)] = profile
|
|
351
|
+
await self._client._cache_set(ref_key(ref), profile, "profile")
|
|
352
|
+
await self._client._cache_set(ref_key(profile.basic_info.user_id), profile, "profile")
|
|
353
|
+
return order_profiles(refs, found)
|
|
354
|
+
|
|
355
|
+
async def by_ids(self, ids: Sequence[int], *, fresh: bool = False) -> dict[int, Profile]:
|
|
356
|
+
return {p.basic_info.user_id: p for p in await self.many(ids, fresh=fresh)}
|
|
357
|
+
|
|
358
|
+
async def by_usernames(self, names: Sequence[str], *, fresh: bool = False) -> dict[str, Profile]:
|
|
359
|
+
by_lower = {p.basic_info.name.lower(): p for p in await self.many(names, fresh=fresh)}
|
|
360
|
+
return {name: by_lower[name.strip().lower()] for name in names if name.strip().lower() in by_lower}
|
|
361
|
+
|
|
362
|
+
async def _load(self, refs: list[PlayerRef], skip_missing: bool) -> list[tuple[PlayerRef, Profile]]:
|
|
363
|
+
ids, names = split_refs(refs)
|
|
364
|
+
try:
|
|
365
|
+
data = await self._client.request("/profile", query={"ids": ids, "usernames": names})
|
|
366
|
+
except CopsApiError as err:
|
|
367
|
+
if not skip_missing or err.code != "user_not_found":
|
|
368
|
+
raise
|
|
369
|
+
if len(refs) <= 1:
|
|
370
|
+
return []
|
|
371
|
+
mid = (len(refs) + 1) // 2
|
|
372
|
+
return [*(await self._load(refs[:mid], True)), *(await self._load(refs[mid:], True))]
|
|
373
|
+
return match_refs(refs, [from_dict(Profile, p) for p in data])
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class AsyncServersApi:
|
|
377
|
+
def __init__(self, client: AsyncCopsClient) -> None:
|
|
378
|
+
self._client = client
|
|
379
|
+
|
|
380
|
+
async def list(self, *, fresh: bool = False) -> list[GameServer]:
|
|
381
|
+
async def load() -> list[GameServer]:
|
|
382
|
+
data = await self._client.request("/status/servers")
|
|
383
|
+
servers = []
|
|
384
|
+
for key, value in data.items():
|
|
385
|
+
if not str(key).isdigit():
|
|
386
|
+
continue
|
|
387
|
+
name = str(value.get("name", ""))
|
|
388
|
+
servers.append(GameServer(int(key), name, value.get("status") == "online", region_of(name)))
|
|
389
|
+
return sorted(servers, key=lambda s: s.id)
|
|
390
|
+
|
|
391
|
+
return await self._client._cached("servers", "servers", fresh, load)
|
|
392
|
+
|
|
393
|
+
async def online(self, *, fresh: bool = False) -> builtins.list[GameServer]:
|
|
394
|
+
return [s for s in await self.list(fresh=fresh) if s.online]
|
|
395
|
+
|
|
396
|
+
async def get(self, server_id: int, *, fresh: bool = False) -> Optional[GameServer]:
|
|
397
|
+
return next((s for s in await self.list(fresh=fresh) if s.id == server_id), None)
|
|
398
|
+
|
|
399
|
+
async def pick(self, *, region: Optional[str] = None, name: Optional[str] = None, fallback_to_any: bool = True, fresh: bool = False) -> Optional[GameServer]:
|
|
400
|
+
online = await self.online(fresh=fresh)
|
|
401
|
+
matches = [
|
|
402
|
+
s
|
|
403
|
+
for s in online
|
|
404
|
+
if (region is None or s.region == region) and (name is None or name.lower() in s.name.lower())
|
|
405
|
+
]
|
|
406
|
+
if matches:
|
|
407
|
+
return random.choice(matches)
|
|
408
|
+
return online[0] if fallback_to_any and online else None
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class AsyncMatchesApi:
|
|
412
|
+
def __init__(self, client: AsyncCopsClient) -> None:
|
|
413
|
+
self._client = client
|
|
414
|
+
|
|
415
|
+
async def history(self, user_id: int, *, fresh: bool = False) -> list[MatchHistoryEntry]:
|
|
416
|
+
async def load() -> list[MatchHistoryEntry]:
|
|
417
|
+
data = await self._client.request("/matches/player", query={"user_id": user_id})
|
|
418
|
+
return [from_dict(MatchHistoryEntry, m) for m in data.get("matches", [])]
|
|
419
|
+
|
|
420
|
+
return await self._client._cached(f"history:{user_id}", "history", fresh, load)
|
|
421
|
+
|
|
422
|
+
async def get(self, ids: Sequence[str], *, fresh: bool = False) -> list[MatchInfo]:
|
|
423
|
+
wanted = check_match_ids(ids)
|
|
424
|
+
found: dict[str, MatchInfo] = {}
|
|
425
|
+
missing: list[str] = []
|
|
426
|
+
for mid in wanted:
|
|
427
|
+
hit = None if fresh else await self._client._cache_get(f"match:{mid}")
|
|
428
|
+
if hit is not None:
|
|
429
|
+
found[mid] = hit
|
|
430
|
+
else:
|
|
431
|
+
missing.append(mid)
|
|
432
|
+
for group in chunks(missing, 20):
|
|
433
|
+
data = await self._client.request("/matches/fetch", query={"ids": group})
|
|
434
|
+
for raw in data.get("matches", []):
|
|
435
|
+
match = from_dict(MatchInfo, raw)
|
|
436
|
+
found[match.id.lower()] = match
|
|
437
|
+
await self._client._cache_set(f"match:{match.id.lower()}", match, "match")
|
|
438
|
+
return [found[mid] for mid in wanted if mid in found]
|
|
439
|
+
|
|
440
|
+
async def one(self, match_id: str, *, fresh: bool = False) -> Optional[MatchInfo]:
|
|
441
|
+
found = await self.get([match_id], fresh=fresh)
|
|
442
|
+
return found[0] if found else None
|
|
443
|
+
|
|
444
|
+
async def recent(self, user_id: int, *, limit: int = 5, game_type: Optional[Union[str, int]] = None, fresh: bool = False) -> list[tuple[MatchHistoryEntry, Optional[MatchInfo]]]:
|
|
445
|
+
history = await self.history(user_id, fresh=fresh)
|
|
446
|
+
if game_type is not None:
|
|
447
|
+
code = game_type_code(game_type)
|
|
448
|
+
history = [h for h in history if h.game_type == code]
|
|
449
|
+
history = history[:limit]
|
|
450
|
+
matches = {m.id.lower(): m for m in await self.get([h.id for h in history], fresh=fresh)} if history else {}
|
|
451
|
+
return [(entry, matches.get(entry.id.lower())) for entry in history]
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
class AsyncStatsApi:
|
|
455
|
+
def __init__(self, client: AsyncCopsClient) -> None:
|
|
456
|
+
self._client = client
|
|
457
|
+
|
|
458
|
+
async def weapons(self, user_id: int, game_type: Union[str, int] = "ranked", *, fresh: bool = False) -> list[WeaponStats]:
|
|
459
|
+
code = game_type_code(game_type)
|
|
460
|
+
|
|
461
|
+
async def load() -> list[WeaponStats]:
|
|
462
|
+
data = await self._client.request("/stats/weapons", query={"user_id": user_id, "game_type": code})
|
|
463
|
+
return [from_dict(WeaponStats, w) for w in data.get("weapons", [])]
|
|
464
|
+
|
|
465
|
+
return await self._client._cached(f"weapons:{user_id}:{code}", "weapons", fresh, load)
|
|
466
|
+
|
|
467
|
+
async def top_weapons(self, user_id: int, *, game_type: Union[str, int] = "ranked", by: str = "k", limit: int = 5, fresh: bool = False) -> list[WeaponStats]:
|
|
468
|
+
if by not in ("k", "a", "h"):
|
|
469
|
+
raise ValueError("by must be 'k', 'a' or 'h'")
|
|
470
|
+
weapons = await self.weapons(user_id, game_type, fresh=fresh)
|
|
471
|
+
return sorted(weapons, key=lambda w: getattr(w, by), reverse=True)[:limit]
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
class AsyncLeaderboardsApi:
|
|
475
|
+
def __init__(self, client: AsyncCopsClient) -> None:
|
|
476
|
+
self._client = client
|
|
477
|
+
|
|
478
|
+
async def page(self, type: str, *, page: int = 1, rank: Optional[int] = None, fresh: bool = False) -> LeaderboardPage:
|
|
479
|
+
check_rank(rank)
|
|
480
|
+
page = max(1, int(page))
|
|
481
|
+
|
|
482
|
+
async def load() -> LeaderboardPage:
|
|
483
|
+
data = await self._client.request("/leaderboards", query={"type": type, "page": page, "rank": rank})
|
|
484
|
+
size = int(data.get("leaderboard_size", 0))
|
|
485
|
+
entries = [from_dict(LeaderboardEntry, e) for e in data.get("entries", [])]
|
|
486
|
+
return LeaderboardPage(type, page, rank, size, -(-size // 10), entries)
|
|
487
|
+
|
|
488
|
+
return await self._client._cached(f"leaderboard:{type}:{rank}:{page}", "leaderboard", fresh, load)
|
|
489
|
+
|
|
490
|
+
async def iterate(self, type: str, *, rank: Optional[int] = None, start_page: int = 1, max_pages: Optional[int] = None, fresh: bool = False) -> AsyncIterator[LeaderboardEntry]:
|
|
491
|
+
seen: set[int] = set()
|
|
492
|
+
page = start_page
|
|
493
|
+
fetched = 0
|
|
494
|
+
while max_pages is None or fetched < max_pages:
|
|
495
|
+
result = await self.page(type, page=page, rank=rank, fresh=fresh)
|
|
496
|
+
fetched += 1
|
|
497
|
+
if not result.entries:
|
|
498
|
+
return
|
|
499
|
+
for entry in result.entries:
|
|
500
|
+
if entry.basic_info.user_id in seen:
|
|
501
|
+
continue
|
|
502
|
+
seen.add(entry.basic_info.user_id)
|
|
503
|
+
yield entry
|
|
504
|
+
if page >= result.pages:
|
|
505
|
+
return
|
|
506
|
+
page += 1
|
|
507
|
+
|
|
508
|
+
async def top(self, type: str, count: int = 10, *, rank: Optional[int] = None, fresh: bool = False) -> list[LeaderboardEntry]:
|
|
509
|
+
out: list[LeaderboardEntry] = []
|
|
510
|
+
async for entry in self.iterate(type, rank=rank, max_pages=-(-count // 10) + 1, fresh=fresh):
|
|
511
|
+
out.append(entry)
|
|
512
|
+
if len(out) >= count:
|
|
513
|
+
break
|
|
514
|
+
return out
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
class AsyncAutohostApi:
|
|
518
|
+
def __init__(self, client: AsyncCopsClient) -> None:
|
|
519
|
+
self._client = client
|
|
520
|
+
|
|
521
|
+
async def is_available(self, *, fresh: bool = False) -> bool:
|
|
522
|
+
known = self._client.autohost_permission
|
|
523
|
+
if known is not None and not fresh:
|
|
524
|
+
return known
|
|
525
|
+
try:
|
|
526
|
+
await self._client.request("/autohost/poll", query={"ids": [1]}, priority="high")
|
|
527
|
+
self._client.set_autohost_permission(True)
|
|
528
|
+
except CopsApiError as err:
|
|
529
|
+
if err.code == "insufficient_permissions":
|
|
530
|
+
self._client.set_autohost_permission(False)
|
|
531
|
+
elif err.code in ("game_not_found", "bad_request"):
|
|
532
|
+
self._client.set_autohost_permission(True)
|
|
533
|
+
else:
|
|
534
|
+
raise
|
|
535
|
+
return self._client.autohost_permission is True
|
|
536
|
+
|
|
537
|
+
async def _call(self, path: str, method: str, **kwargs: Any) -> Any:
|
|
538
|
+
if self._client.autohost_permission is False:
|
|
539
|
+
raise make_error("insufficient_permissions", AUTOHOST_DENIED, 403, method=method, path=path, local=True)
|
|
540
|
+
try:
|
|
541
|
+
result = await self._client.request(path, method=method, priority="high", **kwargs)
|
|
542
|
+
except CopsApiError as err:
|
|
543
|
+
if err.code == "insufficient_permissions" and not err.local:
|
|
544
|
+
self._client.set_autohost_permission(False)
|
|
545
|
+
raise make_error("insufficient_permissions", AUTOHOST_DENIED, err.status, method=method, path=path) from err
|
|
546
|
+
raise
|
|
547
|
+
self._client.set_autohost_permission(True)
|
|
548
|
+
return result
|
|
549
|
+
|
|
550
|
+
async def create(self, *, server_id: int, map: Union[int, str], mode: Union[int, str], room_name: str, password: Optional[str] = None, validate: bool = True) -> AutohostGame:
|
|
551
|
+
body = create_body(server_id, map, mode, room_name, password, validate)
|
|
552
|
+
data = await self._call("/autohost/create", "POST", body=body)
|
|
553
|
+
return from_dict(AutohostGame, data["autohost"])
|
|
554
|
+
|
|
555
|
+
async def poll(self, ids: Sequence[int]) -> list[AutohostGame]:
|
|
556
|
+
unique = list(dict.fromkeys(ids))
|
|
557
|
+
games: list[AutohostGame] = []
|
|
558
|
+
for group in chunks(unique, 50):
|
|
559
|
+
data = await self._call("/autohost/poll", "GET", query={"ids": group})
|
|
560
|
+
games.extend(from_dict(AutohostGame, g) for g in data.get("autohosts", []))
|
|
561
|
+
return games
|
|
562
|
+
|
|
563
|
+
async def get(self, game_id: int) -> Optional[AutohostGame]:
|
|
564
|
+
games = await self.poll([game_id])
|
|
565
|
+
return games[0] if games else None
|
|
566
|
+
|
|
567
|
+
async def control(self, game_id: int, command: str, user_id: Optional[int] = None) -> AutohostGame:
|
|
568
|
+
check_command(command, user_id)
|
|
569
|
+
data = await self._call("/autohost/control", "POST", body={"id": game_id, "command": command, "user_id": user_id})
|
|
570
|
+
return from_dict(AutohostGame, data["autohost"])
|
|
571
|
+
|
|
572
|
+
async def kick(self, game_id: int, user_id: int) -> AutohostGame:
|
|
573
|
+
return await self.control(game_id, "kick", user_id)
|
|
574
|
+
|
|
575
|
+
async def restart(self, game_id: int) -> AutohostGame:
|
|
576
|
+
return await self.control(game_id, "restart")
|
|
577
|
+
|
|
578
|
+
async def add_owner(self, game_id: int, user_id: int) -> AutohostGame:
|
|
579
|
+
return await self.control(game_id, "room_owner_add", user_id)
|
|
580
|
+
|
|
581
|
+
async def remove_owner(self, game_id: int, user_id: int) -> AutohostGame:
|
|
582
|
+
return await self.control(game_id, "room_owner_remove", user_id)
|
|
583
|
+
|
|
584
|
+
async def close(self, game_id: int) -> AutohostGame:
|
|
585
|
+
return await self.control(game_id, "close")
|
|
586
|
+
|
|
587
|
+
async def wait_for_room(self, game_id: int, *, timeout: float = 35.0, interval: float = 1.5) -> AutohostGame:
|
|
588
|
+
interval = max(0.5, interval)
|
|
589
|
+
deadline = time.monotonic() + timeout
|
|
590
|
+
while True:
|
|
591
|
+
game = await self.get(game_id)
|
|
592
|
+
if game is not None and game.room.code:
|
|
593
|
+
return game
|
|
594
|
+
if game is not None and game.status == "ended":
|
|
595
|
+
raise make_error("game_rejected", f"The game server refused game {game_id}: {game.rejection_reason or 'no reason given'}.", path="/autohost/poll")
|
|
596
|
+
if time.monotonic() + interval > deadline:
|
|
597
|
+
if game is None:
|
|
598
|
+
raise make_error("game_gone", f"Game {game_id} is gone: the game server never created its room.", path="/autohost/poll")
|
|
599
|
+
raise make_error("timeout", f"Game {game_id} had no room code after {timeout:g} seconds.", path="/autohost/poll")
|
|
600
|
+
await sleep(interval)
|
|
601
|
+
|
|
602
|
+
async def host(
|
|
603
|
+
self,
|
|
604
|
+
*,
|
|
605
|
+
server_id: int,
|
|
606
|
+
map: Union[int, str],
|
|
607
|
+
mode: Union[int, str],
|
|
608
|
+
room_name: str,
|
|
609
|
+
password: Union[str, bool, None] = None,
|
|
610
|
+
owners: Sequence[int] = (),
|
|
611
|
+
validate: bool = True,
|
|
612
|
+
timeout: float = 35.0,
|
|
613
|
+
interval: float = 1.5,
|
|
614
|
+
) -> HostedRoom:
|
|
615
|
+
if password is False:
|
|
616
|
+
room_password: Optional[str] = None
|
|
617
|
+
elif password is None or password is True:
|
|
618
|
+
room_password = random_password()
|
|
619
|
+
else:
|
|
620
|
+
room_password = str(password)
|
|
621
|
+
created = await self.create(server_id=server_id, map=map, mode=mode, room_name=room_name, password=room_password, validate=validate)
|
|
622
|
+
game = await self.wait_for_room(created.id, timeout=timeout, interval=interval)
|
|
623
|
+
for owner in owners:
|
|
624
|
+
latest = await self.add_owner(created.id, owner)
|
|
625
|
+
if latest.room.code:
|
|
626
|
+
game = latest
|
|
627
|
+
return HostedRoom(created.id, game.room.code or "", room_password, created.server_id, game)
|
|
628
|
+
|
|
629
|
+
def watch(self, *, interval: float = 5.0, missing_grace: float = 60.0, untrack_ended: bool = True) -> AsyncAutohostWatcher:
|
|
630
|
+
return AsyncAutohostWatcher(self._client, interval=interval, missing_grace=missing_grace, untrack_ended=untrack_ended)
|