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.
@@ -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)