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/_sync_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, Iterator, 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_sync 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 AutohostWatcher
|
|
51
|
+
|
|
52
|
+
Hook = Optional[Callable[[dict[str, Any]], Any]]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Transport:
|
|
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
|
+
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
|
+
def send() -> Any:
|
|
114
|
+
return self._send(path, method, params, body, priority, authenticated)
|
|
115
|
+
|
|
116
|
+
return self._gate.run(key, send)
|
|
117
|
+
|
|
118
|
+
def _retry(self, info: dict[str, Any], delay: float) -> None:
|
|
119
|
+
if self._on_retry:
|
|
120
|
+
self._on_retry(info)
|
|
121
|
+
sleep(delay)
|
|
122
|
+
|
|
123
|
+
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
|
+
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 = 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
|
+
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
|
+
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
|
+
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
|
+
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
|
+
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 CopsClient:
|
|
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 = Transport(
|
|
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 = ProfilesApi(self)
|
|
255
|
+
self.servers = ServersApi(self)
|
|
256
|
+
self.matches = MatchesApi(self)
|
|
257
|
+
self.stats = StatsApi(self)
|
|
258
|
+
self.leaderboards = LeaderboardsApi(self)
|
|
259
|
+
self.autohost = AutohostApi(self)
|
|
260
|
+
|
|
261
|
+
def __repr__(self) -> str:
|
|
262
|
+
return f"{type(self).__name__}(base_url={self.base_url!r})"
|
|
263
|
+
|
|
264
|
+
def __enter__(self) -> CopsClient:
|
|
265
|
+
return self
|
|
266
|
+
|
|
267
|
+
def __exit__(self, *exc: Any) -> None:
|
|
268
|
+
self.close()
|
|
269
|
+
|
|
270
|
+
def close(self) -> None:
|
|
271
|
+
if self._owns_http:
|
|
272
|
+
self._http.close()
|
|
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
|
+
def request(self, path: str, **kwargs: Any) -> Any:
|
|
286
|
+
return self._transport.request(path, **kwargs)
|
|
287
|
+
|
|
288
|
+
def open_api_spec(self) -> dict[str, Any]:
|
|
289
|
+
return self._transport.request("/openapi.json", authenticated=False)
|
|
290
|
+
|
|
291
|
+
def clear_cache(self) -> None:
|
|
292
|
+
if self._store is not None and hasattr(self._store, "clear"):
|
|
293
|
+
resolve(self._store.clear())
|
|
294
|
+
|
|
295
|
+
def verify_token(self) -> TokenCheck:
|
|
296
|
+
try:
|
|
297
|
+
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 = self.autohost.is_available(fresh=True)
|
|
303
|
+
return TokenCheck(True, autohost, None, self.quota)
|
|
304
|
+
|
|
305
|
+
def _cache_get(self, key: str) -> Any:
|
|
306
|
+
if self._store is None:
|
|
307
|
+
return None
|
|
308
|
+
return resolve(self._store.get(key))
|
|
309
|
+
|
|
310
|
+
def _cache_set(self, key: str, value: Any, kind: str) -> None:
|
|
311
|
+
if self._store is not None:
|
|
312
|
+
resolve(self._store.set(key, value, self._ttls[kind]))
|
|
313
|
+
|
|
314
|
+
def _cached(self, key: str, kind: str, fresh: bool, loader: Callable[[], Any]) -> Any:
|
|
315
|
+
if not fresh:
|
|
316
|
+
hit = self._cache_get(key)
|
|
317
|
+
if hit is not None:
|
|
318
|
+
return hit
|
|
319
|
+
value = loader()
|
|
320
|
+
self._cache_set(key, value, kind)
|
|
321
|
+
return value
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class ProfilesApi:
|
|
325
|
+
def __init__(self, client: CopsClient) -> None:
|
|
326
|
+
self._client = client
|
|
327
|
+
|
|
328
|
+
def get(self, player: PlayerRef, *, fresh: bool = False) -> Profile:
|
|
329
|
+
found = 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
|
+
def find(self, player: PlayerRef, *, fresh: bool = False) -> Optional[Profile]:
|
|
335
|
+
found = self.many([player], fresh=fresh)
|
|
336
|
+
return found[0] if found else None
|
|
337
|
+
|
|
338
|
+
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 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 self._load(group, skip_missing):
|
|
350
|
+
found[ref_key(ref)] = profile
|
|
351
|
+
self._client._cache_set(ref_key(ref), profile, "profile")
|
|
352
|
+
self._client._cache_set(ref_key(profile.basic_info.user_id), profile, "profile")
|
|
353
|
+
return order_profiles(refs, found)
|
|
354
|
+
|
|
355
|
+
def by_ids(self, ids: Sequence[int], *, fresh: bool = False) -> dict[int, Profile]:
|
|
356
|
+
return {p.basic_info.user_id: p for p in self.many(ids, fresh=fresh)}
|
|
357
|
+
|
|
358
|
+
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 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
|
+
def _load(self, refs: list[PlayerRef], skip_missing: bool) -> list[tuple[PlayerRef, Profile]]:
|
|
363
|
+
ids, names = split_refs(refs)
|
|
364
|
+
try:
|
|
365
|
+
data = 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 [*(self._load(refs[:mid], True)), *(self._load(refs[mid:], True))]
|
|
373
|
+
return match_refs(refs, [from_dict(Profile, p) for p in data])
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class ServersApi:
|
|
377
|
+
def __init__(self, client: CopsClient) -> None:
|
|
378
|
+
self._client = client
|
|
379
|
+
|
|
380
|
+
def list(self, *, fresh: bool = False) -> list[GameServer]:
|
|
381
|
+
def load() -> list[GameServer]:
|
|
382
|
+
data = 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 self._client._cached("servers", "servers", fresh, load)
|
|
392
|
+
|
|
393
|
+
def online(self, *, fresh: bool = False) -> builtins.list[GameServer]:
|
|
394
|
+
return [s for s in self.list(fresh=fresh) if s.online]
|
|
395
|
+
|
|
396
|
+
def get(self, server_id: int, *, fresh: bool = False) -> Optional[GameServer]:
|
|
397
|
+
return next((s for s in self.list(fresh=fresh) if s.id == server_id), None)
|
|
398
|
+
|
|
399
|
+
def pick(self, *, region: Optional[str] = None, name: Optional[str] = None, fallback_to_any: bool = True, fresh: bool = False) -> Optional[GameServer]:
|
|
400
|
+
online = 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 MatchesApi:
|
|
412
|
+
def __init__(self, client: CopsClient) -> None:
|
|
413
|
+
self._client = client
|
|
414
|
+
|
|
415
|
+
def history(self, user_id: int, *, fresh: bool = False) -> list[MatchHistoryEntry]:
|
|
416
|
+
def load() -> list[MatchHistoryEntry]:
|
|
417
|
+
data = 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 self._client._cached(f"history:{user_id}", "history", fresh, load)
|
|
421
|
+
|
|
422
|
+
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 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 = 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
|
+
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
|
+
def one(self, match_id: str, *, fresh: bool = False) -> Optional[MatchInfo]:
|
|
441
|
+
found = self.get([match_id], fresh=fresh)
|
|
442
|
+
return found[0] if found else None
|
|
443
|
+
|
|
444
|
+
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 = 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 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 StatsApi:
|
|
455
|
+
def __init__(self, client: CopsClient) -> None:
|
|
456
|
+
self._client = client
|
|
457
|
+
|
|
458
|
+
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
|
+
def load() -> list[WeaponStats]:
|
|
462
|
+
data = 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 self._client._cached(f"weapons:{user_id}:{code}", "weapons", fresh, load)
|
|
466
|
+
|
|
467
|
+
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 = 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 LeaderboardsApi:
|
|
475
|
+
def __init__(self, client: CopsClient) -> None:
|
|
476
|
+
self._client = client
|
|
477
|
+
|
|
478
|
+
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
|
+
def load() -> LeaderboardPage:
|
|
483
|
+
data = 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 self._client._cached(f"leaderboard:{type}:{rank}:{page}", "leaderboard", fresh, load)
|
|
489
|
+
|
|
490
|
+
def iterate(self, type: str, *, rank: Optional[int] = None, start_page: int = 1, max_pages: Optional[int] = None, fresh: bool = False) -> Iterator[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 = 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
|
+
def top(self, type: str, count: int = 10, *, rank: Optional[int] = None, fresh: bool = False) -> list[LeaderboardEntry]:
|
|
509
|
+
out: list[LeaderboardEntry] = []
|
|
510
|
+
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 AutohostApi:
|
|
518
|
+
def __init__(self, client: CopsClient) -> None:
|
|
519
|
+
self._client = client
|
|
520
|
+
|
|
521
|
+
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
|
+
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
|
+
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 = 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
|
+
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 = self._call("/autohost/create", "POST", body=body)
|
|
553
|
+
return from_dict(AutohostGame, data["autohost"])
|
|
554
|
+
|
|
555
|
+
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 = 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
|
+
def get(self, game_id: int) -> Optional[AutohostGame]:
|
|
564
|
+
games = self.poll([game_id])
|
|
565
|
+
return games[0] if games else None
|
|
566
|
+
|
|
567
|
+
def control(self, game_id: int, command: str, user_id: Optional[int] = None) -> AutohostGame:
|
|
568
|
+
check_command(command, user_id)
|
|
569
|
+
data = self._call("/autohost/control", "POST", body={"id": game_id, "command": command, "user_id": user_id})
|
|
570
|
+
return from_dict(AutohostGame, data["autohost"])
|
|
571
|
+
|
|
572
|
+
def kick(self, game_id: int, user_id: int) -> AutohostGame:
|
|
573
|
+
return self.control(game_id, "kick", user_id)
|
|
574
|
+
|
|
575
|
+
def restart(self, game_id: int) -> AutohostGame:
|
|
576
|
+
return self.control(game_id, "restart")
|
|
577
|
+
|
|
578
|
+
def add_owner(self, game_id: int, user_id: int) -> AutohostGame:
|
|
579
|
+
return self.control(game_id, "room_owner_add", user_id)
|
|
580
|
+
|
|
581
|
+
def remove_owner(self, game_id: int, user_id: int) -> AutohostGame:
|
|
582
|
+
return self.control(game_id, "room_owner_remove", user_id)
|
|
583
|
+
|
|
584
|
+
def close(self, game_id: int) -> AutohostGame:
|
|
585
|
+
return self.control(game_id, "close")
|
|
586
|
+
|
|
587
|
+
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 = 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
|
+
sleep(interval)
|
|
601
|
+
|
|
602
|
+
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 = self.create(server_id=server_id, map=map, mode=mode, room_name=room_name, password=room_password, validate=validate)
|
|
622
|
+
game = self.wait_for_room(created.id, timeout=timeout, interval=interval)
|
|
623
|
+
for owner in owners:
|
|
624
|
+
latest = 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) -> AutohostWatcher:
|
|
630
|
+
return AutohostWatcher(self._client, interval=interval, missing_grace=missing_grace, untrack_ended=untrack_ended)
|