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/watcher.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Callable, Optional
|
|
9
|
+
|
|
10
|
+
from .errors import CopsApiError
|
|
11
|
+
from .models import AutohostGame
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from ._async_client import AsyncCopsClient
|
|
15
|
+
from ._sync_client import CopsClient
|
|
16
|
+
|
|
17
|
+
EVENTS = frozenset(
|
|
18
|
+
{
|
|
19
|
+
"update",
|
|
20
|
+
"room_ready",
|
|
21
|
+
"rejected",
|
|
22
|
+
"state",
|
|
23
|
+
"match_start",
|
|
24
|
+
"score",
|
|
25
|
+
"player_join",
|
|
26
|
+
"player_leave",
|
|
27
|
+
"player_disconnect",
|
|
28
|
+
"player_reconnect",
|
|
29
|
+
"player_team_change",
|
|
30
|
+
"match_result",
|
|
31
|
+
"ended",
|
|
32
|
+
"gone",
|
|
33
|
+
"poll_error",
|
|
34
|
+
"handler_error",
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
FATAL_CODES = frozenset({"insufficient_permissions", "invalid_token", "token_revoked", "missing_token"})
|
|
39
|
+
|
|
40
|
+
Event = tuple[str, tuple[Any, ...]]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class JoinInfo:
|
|
45
|
+
first_seen_at: float
|
|
46
|
+
since_room_ready: Optional[float]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class _Tracked:
|
|
51
|
+
id: int
|
|
52
|
+
meta: Any
|
|
53
|
+
added_at: float
|
|
54
|
+
seen_results: int
|
|
55
|
+
game: Optional[AutohostGame] = None
|
|
56
|
+
last_seen_at: Optional[float] = None
|
|
57
|
+
room_ready_at: Optional[float] = None
|
|
58
|
+
joins: dict[int, JoinInfo] = field(default_factory=dict)
|
|
59
|
+
offline_since: dict[int, float] = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _Engine:
|
|
63
|
+
def __init__(self, missing_grace: float, untrack_ended: bool, clock: Callable[[], float]) -> None:
|
|
64
|
+
self.missing_grace = missing_grace
|
|
65
|
+
self.untrack_ended = untrack_ended
|
|
66
|
+
self.clock = clock
|
|
67
|
+
self.games: dict[int, _Tracked] = {}
|
|
68
|
+
|
|
69
|
+
def track(self, game_id: int, meta: Any, seen_results: int) -> None:
|
|
70
|
+
if game_id not in self.games:
|
|
71
|
+
self.games[game_id] = _Tracked(game_id, meta, self.clock(), seen_results)
|
|
72
|
+
|
|
73
|
+
def apply(self, ids: list[int], games: list[AutohostGame]) -> list[Event]:
|
|
74
|
+
now = self.clock()
|
|
75
|
+
events: list[Event] = []
|
|
76
|
+
returned: set[int] = set()
|
|
77
|
+
for game in games:
|
|
78
|
+
tracked = self.games.get(game.id)
|
|
79
|
+
if tracked is None:
|
|
80
|
+
continue
|
|
81
|
+
returned.add(game.id)
|
|
82
|
+
tracked.last_seen_at = now
|
|
83
|
+
self._diff(tracked, game, now, events)
|
|
84
|
+
for game_id in ids:
|
|
85
|
+
tracked = self.games.get(game_id)
|
|
86
|
+
if game_id in returned or tracked is None:
|
|
87
|
+
continue
|
|
88
|
+
if now - (tracked.last_seen_at or tracked.added_at) >= self.missing_grace:
|
|
89
|
+
del self.games[game_id]
|
|
90
|
+
events.append(("gone", (game_id, tracked.game)))
|
|
91
|
+
return events
|
|
92
|
+
|
|
93
|
+
def _diff(self, t: _Tracked, nxt: AutohostGame, now: float, events: list[Event]) -> None:
|
|
94
|
+
prev = t.game
|
|
95
|
+
t.game = nxt
|
|
96
|
+
events.append(("update", (nxt, prev)))
|
|
97
|
+
if not (prev and prev.room.code) and nxt.room.code:
|
|
98
|
+
t.room_ready_at = now
|
|
99
|
+
events.append(("room_ready", (nxt,)))
|
|
100
|
+
prev_state = prev.match_state if prev else None
|
|
101
|
+
if nxt.match_state is not None and nxt.match_state != prev_state:
|
|
102
|
+
events.append(("state", (nxt, nxt.match_state, prev_state)))
|
|
103
|
+
prev_started = prev.match_started_at if prev else None
|
|
104
|
+
if nxt.match_started_at is not None and nxt.match_started_at != prev_started:
|
|
105
|
+
events.append(("match_start", (nxt,)))
|
|
106
|
+
if prev and nxt.status == "open" and nxt.scores and (nxt.round != prev.round or nxt.scores != prev.scores):
|
|
107
|
+
events.append(("score", (nxt, nxt.round, list(nxt.scores))))
|
|
108
|
+
if nxt.status == "open":
|
|
109
|
+
self._diff_players(t, prev, nxt, now, events)
|
|
110
|
+
for index in range(t.seen_results, len(nxt.results)):
|
|
111
|
+
events.append(("match_result", (nxt, nxt.results[index], index)))
|
|
112
|
+
t.seen_results = max(t.seen_results, len(nxt.results))
|
|
113
|
+
if nxt.status == "ended" and (prev is None or prev.status != "ended"):
|
|
114
|
+
if nxt.room.id == -1 or (t.room_ready_at is None and not nxt.results and nxt.rejection_reason):
|
|
115
|
+
events.append(("rejected", (nxt, nxt.rejection_reason)))
|
|
116
|
+
events.append(("ended", (nxt,)))
|
|
117
|
+
if self.untrack_ended:
|
|
118
|
+
self.games.pop(t.id, None)
|
|
119
|
+
|
|
120
|
+
def _diff_players(self, t: _Tracked, prev: Optional[AutohostGame], nxt: AutohostGame, now: float, events: list[Event]) -> None:
|
|
121
|
+
before = {p.user_id: p for p in (prev.players if prev else [])}
|
|
122
|
+
after = {p.user_id: p for p in nxt.players}
|
|
123
|
+
for user_id, player in after.items():
|
|
124
|
+
old = before.get(user_id)
|
|
125
|
+
if old is None:
|
|
126
|
+
if user_id not in t.joins:
|
|
127
|
+
since = now - t.room_ready_at if t.room_ready_at is not None else None
|
|
128
|
+
t.joins[user_id] = JoinInfo(now, since)
|
|
129
|
+
events.append(("player_join", (nxt, player, t.joins[user_id])))
|
|
130
|
+
if player.ping < 0:
|
|
131
|
+
t.offline_since[user_id] = now
|
|
132
|
+
continue
|
|
133
|
+
if old.ping >= 0 and player.ping < 0:
|
|
134
|
+
t.offline_since[user_id] = now
|
|
135
|
+
events.append(("player_disconnect", (nxt, player)))
|
|
136
|
+
elif old.ping < 0 and player.ping >= 0:
|
|
137
|
+
since = t.offline_since.pop(user_id, None)
|
|
138
|
+
events.append(("player_reconnect", (nxt, player, now - since if since is not None else 0.0)))
|
|
139
|
+
if old.team != player.team:
|
|
140
|
+
events.append(("player_team_change", (nxt, player, old.team)))
|
|
141
|
+
for user_id, player in before.items():
|
|
142
|
+
if user_id not in after:
|
|
143
|
+
t.offline_since.pop(user_id, None)
|
|
144
|
+
events.append(("player_leave", (nxt, player)))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class _WatcherBase:
|
|
148
|
+
def __init__(self, interval: float, missing_grace: float, untrack_ended: bool, clock: Callable[[], float] = time.monotonic) -> None:
|
|
149
|
+
self.interval = max(1.0, interval)
|
|
150
|
+
self._engine = _Engine(missing_grace, untrack_ended, clock)
|
|
151
|
+
self._handlers: dict[str, list[Callable[..., Any]]] = {}
|
|
152
|
+
|
|
153
|
+
def on(self, event: str, handler: Optional[Callable[..., Any]] = None) -> Any:
|
|
154
|
+
if event not in EVENTS:
|
|
155
|
+
raise ValueError(f"Unknown event {event!r}. Events: {', '.join(sorted(EVENTS))}")
|
|
156
|
+
|
|
157
|
+
def register(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
158
|
+
self._handlers.setdefault(event, []).append(fn)
|
|
159
|
+
return fn
|
|
160
|
+
|
|
161
|
+
return register(handler) if handler is not None else register
|
|
162
|
+
|
|
163
|
+
def off(self, event: str, handler: Callable[..., Any]) -> None:
|
|
164
|
+
handlers = self._handlers.get(event, [])
|
|
165
|
+
if handler in handlers:
|
|
166
|
+
handlers.remove(handler)
|
|
167
|
+
|
|
168
|
+
def track(self, game: Any, *, meta: Any = None, seen_results: int = 0) -> Any:
|
|
169
|
+
game_id = game.id if isinstance(game, AutohostGame) else int(game)
|
|
170
|
+
self._engine.track(game_id, meta, seen_results)
|
|
171
|
+
return self
|
|
172
|
+
|
|
173
|
+
def untrack(self, game_id: int) -> bool:
|
|
174
|
+
return self._engine.games.pop(game_id, None) is not None
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def ids(self) -> list[int]:
|
|
178
|
+
return list(self._engine.games)
|
|
179
|
+
|
|
180
|
+
def __len__(self) -> int:
|
|
181
|
+
return len(self._engine.games)
|
|
182
|
+
|
|
183
|
+
def latest(self, game_id: int) -> Optional[AutohostGame]:
|
|
184
|
+
tracked = self._engine.games.get(game_id)
|
|
185
|
+
return tracked.game if tracked else None
|
|
186
|
+
|
|
187
|
+
def meta(self, game_id: int) -> Any:
|
|
188
|
+
tracked = self._engine.games.get(game_id)
|
|
189
|
+
return tracked.meta if tracked else None
|
|
190
|
+
|
|
191
|
+
def join_times(self, game_id: int) -> dict[int, JoinInfo]:
|
|
192
|
+
tracked = self._engine.games.get(game_id)
|
|
193
|
+
return dict(tracked.joins) if tracked else {}
|
|
194
|
+
|
|
195
|
+
def room_ready_at(self, game_id: int) -> Optional[float]:
|
|
196
|
+
tracked = self._engine.games.get(game_id)
|
|
197
|
+
return tracked.room_ready_at if tracked else None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class AsyncAutohostWatcher(_WatcherBase):
|
|
201
|
+
def __init__(self, client: AsyncCopsClient, *, interval: float = 5.0, missing_grace: float = 60.0, untrack_ended: bool = True) -> None:
|
|
202
|
+
super().__init__(interval, missing_grace, untrack_ended)
|
|
203
|
+
self._client = client
|
|
204
|
+
self._task: Optional[asyncio.Task[None]] = None
|
|
205
|
+
self._lock: Optional[asyncio.Lock] = None
|
|
206
|
+
self._stopping = False
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def running(self) -> bool:
|
|
210
|
+
return self._task is not None and not self._task.done()
|
|
211
|
+
|
|
212
|
+
async def _emit(self, event: str, *args: Any) -> None:
|
|
213
|
+
for handler in list(self._handlers.get(event, [])):
|
|
214
|
+
try:
|
|
215
|
+
result = handler(*args)
|
|
216
|
+
if inspect.isawaitable(result):
|
|
217
|
+
await result
|
|
218
|
+
except Exception as err:
|
|
219
|
+
if event == "handler_error":
|
|
220
|
+
raise
|
|
221
|
+
await self._emit("handler_error", err, event)
|
|
222
|
+
|
|
223
|
+
async def poll_now(self) -> None:
|
|
224
|
+
if self._lock is None:
|
|
225
|
+
self._lock = asyncio.Lock()
|
|
226
|
+
async with self._lock:
|
|
227
|
+
ids = self.ids
|
|
228
|
+
if not ids:
|
|
229
|
+
return
|
|
230
|
+
try:
|
|
231
|
+
games = await self._client.autohost.poll(ids)
|
|
232
|
+
except CopsApiError as err:
|
|
233
|
+
await self._emit("poll_error", err)
|
|
234
|
+
if err.code in FATAL_CODES:
|
|
235
|
+
self._stopping = True
|
|
236
|
+
return
|
|
237
|
+
except Exception as err:
|
|
238
|
+
await self._emit("poll_error", err)
|
|
239
|
+
return
|
|
240
|
+
for event, args in self._engine.apply(ids, games):
|
|
241
|
+
await self._emit(event, *args)
|
|
242
|
+
|
|
243
|
+
async def _loop(self, until_empty: bool) -> None:
|
|
244
|
+
while not self._stopping:
|
|
245
|
+
started = time.monotonic()
|
|
246
|
+
await self.poll_now()
|
|
247
|
+
if self._stopping or (until_empty and not self.ids):
|
|
248
|
+
return
|
|
249
|
+
await asyncio.sleep(max(0.0, self.interval - (time.monotonic() - started)))
|
|
250
|
+
|
|
251
|
+
def start(self) -> AsyncAutohostWatcher:
|
|
252
|
+
if not self.running:
|
|
253
|
+
self._stopping = False
|
|
254
|
+
self._task = asyncio.get_running_loop().create_task(self._loop(False))
|
|
255
|
+
return self
|
|
256
|
+
|
|
257
|
+
async def run(self) -> None:
|
|
258
|
+
self._stopping = False
|
|
259
|
+
await self._loop(True)
|
|
260
|
+
|
|
261
|
+
async def stop(self) -> None:
|
|
262
|
+
self._stopping = True
|
|
263
|
+
task, self._task = self._task, None
|
|
264
|
+
if task is not None and not task.done():
|
|
265
|
+
task.cancel()
|
|
266
|
+
try:
|
|
267
|
+
await task
|
|
268
|
+
except asyncio.CancelledError:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
async def __aenter__(self) -> AsyncAutohostWatcher:
|
|
272
|
+
return self.start()
|
|
273
|
+
|
|
274
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
275
|
+
await self.stop()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class AutohostWatcher(_WatcherBase):
|
|
279
|
+
def __init__(self, client: CopsClient, *, interval: float = 5.0, missing_grace: float = 60.0, untrack_ended: bool = True) -> None:
|
|
280
|
+
super().__init__(interval, missing_grace, untrack_ended)
|
|
281
|
+
self._client = client
|
|
282
|
+
self._thread: Optional[threading.Thread] = None
|
|
283
|
+
self._stop = threading.Event()
|
|
284
|
+
self._lock = threading.Lock()
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def running(self) -> bool:
|
|
288
|
+
return self._thread is not None and self._thread.is_alive()
|
|
289
|
+
|
|
290
|
+
def _emit(self, event: str, *args: Any) -> None:
|
|
291
|
+
for handler in list(self._handlers.get(event, [])):
|
|
292
|
+
try:
|
|
293
|
+
handler(*args)
|
|
294
|
+
except Exception as err:
|
|
295
|
+
if event == "handler_error":
|
|
296
|
+
raise
|
|
297
|
+
self._emit("handler_error", err, event)
|
|
298
|
+
|
|
299
|
+
def poll_now(self) -> None:
|
|
300
|
+
with self._lock:
|
|
301
|
+
ids = self.ids
|
|
302
|
+
if not ids:
|
|
303
|
+
return
|
|
304
|
+
try:
|
|
305
|
+
games = self._client.autohost.poll(ids)
|
|
306
|
+
except CopsApiError as err:
|
|
307
|
+
self._emit("poll_error", err)
|
|
308
|
+
if err.code in FATAL_CODES:
|
|
309
|
+
self._stop.set()
|
|
310
|
+
return
|
|
311
|
+
except Exception as err:
|
|
312
|
+
self._emit("poll_error", err)
|
|
313
|
+
return
|
|
314
|
+
for event, args in self._engine.apply(ids, games):
|
|
315
|
+
self._emit(event, *args)
|
|
316
|
+
|
|
317
|
+
def _loop(self, until_empty: bool) -> None:
|
|
318
|
+
while not self._stop.is_set():
|
|
319
|
+
started = time.monotonic()
|
|
320
|
+
self.poll_now()
|
|
321
|
+
if self._stop.is_set() or (until_empty and not self.ids):
|
|
322
|
+
return
|
|
323
|
+
self._stop.wait(max(0.0, self.interval - (time.monotonic() - started)))
|
|
324
|
+
|
|
325
|
+
def start(self) -> AutohostWatcher:
|
|
326
|
+
if not self.running:
|
|
327
|
+
self._stop.clear()
|
|
328
|
+
self._thread = threading.Thread(target=self._loop, args=(False,), name="copsapi-watcher", daemon=True)
|
|
329
|
+
self._thread.start()
|
|
330
|
+
return self
|
|
331
|
+
|
|
332
|
+
def run(self) -> None:
|
|
333
|
+
self._stop.clear()
|
|
334
|
+
self._loop(True)
|
|
335
|
+
|
|
336
|
+
def stop(self, timeout: Optional[float] = 10.0) -> None:
|
|
337
|
+
self._stop.set()
|
|
338
|
+
thread, self._thread = self._thread, None
|
|
339
|
+
if thread is not None and thread is not threading.current_thread():
|
|
340
|
+
thread.join(timeout)
|
|
341
|
+
|
|
342
|
+
def __enter__(self) -> AutohostWatcher:
|
|
343
|
+
return self.start()
|
|
344
|
+
|
|
345
|
+
def __exit__(self, *exc: Any) -> None:
|
|
346
|
+
self.stop()
|