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/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()