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/_core.py ADDED
@@ -0,0 +1,216 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from typing import Any, Iterable, Optional, Sequence, TypeVar, Union
7
+
8
+ from .catalog import GAME_TYPES, game_mode, is_hostable, map_id, map_name
9
+ from .errors import API_CODES, CopsApiError, make_error
10
+ from .models import Profile
11
+
12
+ T = TypeVar("T")
13
+
14
+ VERSION = "1.0.0"
15
+ DEFAULT_BASE_URL = "https://latest.prod.copsapi.criticalforce.fi/api/public/v2"
16
+ TOKEN_PATTERN = re.compile(r"^cops_[0-9a-f]{48}$", re.I)
17
+ MATCH_ID_PATTERN = re.compile(r"^[0-9a-f]{64}$")
18
+
19
+ AUTOHOST_DENIED = (
20
+ "Your API token does not have the Autohost permission, which hosting games (autohost.create, poll and control) "
21
+ "needs. Ask Critical Force to grant it to your token; every other endpoint works without it."
22
+ )
23
+
24
+ PlayerRef = Union[int, str]
25
+
26
+
27
+ def resolve_token(token: Optional[str], validate: bool) -> str:
28
+ value = (token if token is not None else os.environ.get("COPS_API_TOKEN", "")).strip()
29
+ if not value:
30
+ raise make_error(
31
+ "missing_token",
32
+ 'CopsClient needs your own Critical Ops API token: CopsClient("cops_...") or set COPS_API_TOKEN. '
33
+ "Tokens are issued by Critical Force to a Critical Ops account.",
34
+ local=True,
35
+ )
36
+ if validate and not TOKEN_PATTERN.match(value):
37
+ raise make_error(
38
+ "invalid_token",
39
+ 'That does not look like a Critical Ops API token: tokens are "cops_" followed by 48 hex digits. '
40
+ "Pass validate_token=False if the format has changed.",
41
+ local=True,
42
+ )
43
+ return value
44
+
45
+
46
+ def resolve_base_url(base_url: Optional[str]) -> str:
47
+ return (base_url or os.environ.get("COPS_API_BASE") or DEFAULT_BASE_URL).rstrip("/")
48
+
49
+
50
+ def build_query(query: Optional[dict[str, Any]]) -> dict[str, str]:
51
+ out: dict[str, str] = {}
52
+ for key, value in (query or {}).items():
53
+ if value is None:
54
+ continue
55
+ if isinstance(value, (list, tuple)):
56
+ if value:
57
+ out[key] = ",".join(str(v) for v in value)
58
+ elif isinstance(value, bool):
59
+ out[key] = "true" if value else "false"
60
+ else:
61
+ out[key] = str(value)
62
+ return out
63
+
64
+
65
+ def fallback_code(status: int) -> str:
66
+ if status == 401:
67
+ return "invalid_token"
68
+ if status == 403:
69
+ return "insufficient_permissions"
70
+ if status == 429:
71
+ return "rate_limited"
72
+ if status == 503:
73
+ return "server_unavailable"
74
+ if status >= 500:
75
+ return "internal_error"
76
+ return "bad_request"
77
+
78
+
79
+ def positive(value: Any) -> Optional[float]:
80
+ try:
81
+ number = float(value)
82
+ except (TypeError, ValueError):
83
+ return None
84
+ return number if number > 0 else None
85
+
86
+
87
+ def parse_json(text: str) -> Any:
88
+ if not text:
89
+ return None
90
+ try:
91
+ return json.loads(text)
92
+ except ValueError:
93
+ return None
94
+
95
+
96
+ def api_error(status: int, body: Any, headers: Any, method: str, path: str) -> CopsApiError:
97
+ detail: dict[str, Any] = body["error"] if isinstance(body, dict) and isinstance(body.get("error"), dict) else {}
98
+ raw_code = detail.get("code")
99
+ code = raw_code if raw_code in API_CODES else fallback_code(status)
100
+ retry_after = positive(headers.get("retry-after")) or positive(detail.get("retry_after"))
101
+ message = detail.get("message") or f"Critical Ops API error {status}"
102
+ return make_error(code, message, status, retry_after, method=method, path=path)
103
+
104
+
105
+ def local_error(code: str, message: str, path: str, method: str = "GET") -> CopsApiError:
106
+ return make_error(code, message, 0, None, method=method, path=path, local=True)
107
+
108
+
109
+ def chunks(items: Sequence[T], size: int) -> list[list[T]]:
110
+ return [list(items[i : i + size]) for i in range(0, len(items), size)]
111
+
112
+
113
+ def ref_key(ref: PlayerRef) -> str:
114
+ return f"profile:id:{ref}" if isinstance(ref, int) else f"profile:name:{ref.strip().lower()}"
115
+
116
+
117
+ def dedupe_refs(players: Iterable[PlayerRef]) -> list[PlayerRef]:
118
+ seen: set[str] = set()
119
+ out: list[PlayerRef] = []
120
+ for raw in players:
121
+ if isinstance(raw, bool) or (isinstance(raw, int) and raw <= 0):
122
+ raise local_error("bad_request", f"Invalid player id {raw!r}.", "/profile")
123
+ ref: PlayerRef = raw if isinstance(raw, int) else str(raw).strip()
124
+ if ref == "":
125
+ continue
126
+ key = ref_key(ref)
127
+ if key in seen:
128
+ continue
129
+ seen.add(key)
130
+ out.append(ref)
131
+ return out
132
+
133
+
134
+ def split_refs(refs: Sequence[PlayerRef]) -> tuple[list[int], list[str]]:
135
+ return [r for r in refs if isinstance(r, int)], [r for r in refs if isinstance(r, str)]
136
+
137
+
138
+ def match_refs(refs: Sequence[PlayerRef], profiles: Sequence[Profile]) -> list[tuple[PlayerRef, Profile]]:
139
+ ids, names = split_refs(refs)
140
+ by_id = {p.basic_info.user_id: p for p in profiles}
141
+ by_name = {p.basic_info.name.lower(): p for p in profiles}
142
+ out: list[tuple[PlayerRef, Profile]] = [(i, by_id[i]) for i in ids if i in by_id]
143
+ unmatched = [p for p in profiles if p.basic_info.user_id not in ids]
144
+ for index, name in enumerate(names):
145
+ hit = by_name.get(name.lower()) or (unmatched[index] if index < len(unmatched) else None)
146
+ if hit is not None:
147
+ out.append((name, hit))
148
+ return out
149
+
150
+
151
+ def order_profiles(refs: Sequence[PlayerRef], found: dict[str, Profile]) -> list[Profile]:
152
+ seen: set[int] = set()
153
+ out: list[Profile] = []
154
+ for ref in refs:
155
+ profile = found.get(ref_key(ref))
156
+ if profile is not None and profile.basic_info.user_id not in seen:
157
+ seen.add(profile.basic_info.user_id)
158
+ out.append(profile)
159
+ return out
160
+
161
+
162
+ def game_type_code(game_type: Union[str, int]) -> int:
163
+ if isinstance(game_type, int):
164
+ if game_type not in (1, 2):
165
+ raise ValueError("game_type must be 1 (ranked) or 2 (casual)")
166
+ return game_type
167
+ if game_type not in GAME_TYPES:
168
+ raise ValueError("game_type must be 'ranked' or 'casual'")
169
+ return GAME_TYPES[game_type]
170
+
171
+
172
+ def check_match_ids(ids: Iterable[str]) -> list[str]:
173
+ out: list[str] = []
174
+ for raw in ids:
175
+ mid = raw.strip().lower()
176
+ if not MATCH_ID_PATTERN.match(mid):
177
+ raise local_error("bad_request", f"Invalid match id {raw!r}: expected 64 hex digits.", "/matches/fetch")
178
+ if mid not in out:
179
+ out.append(mid)
180
+ return out
181
+
182
+
183
+ def check_rank(rank: Optional[int]) -> None:
184
+ if rank is not None and not (isinstance(rank, int) and 0 <= rank <= 9):
185
+ raise local_error("bad_request", f"rank must be 0 to 9, got {rank!r}.", "/leaderboards")
186
+
187
+
188
+ def create_body(server_id: int, map_: Union[int, str], mode: Union[int, str], room_name: str, password: Optional[str], validate: bool) -> dict[str, Any]:
189
+ path = "/autohost/create"
190
+ found_mode = game_mode(mode)
191
+ found_map = map_id(map_)
192
+ mode_id = found_mode.id if found_mode else (mode if isinstance(mode, int) else None)
193
+ map_value = found_map if found_map is not None else (map_ if isinstance(map_, int) else None)
194
+ if mode_id is None:
195
+ raise local_error("bad_request", f"Unknown game mode {mode!r}.", path, "POST")
196
+ if map_value is None:
197
+ raise local_error("bad_request", f"Unknown map {map_!r}.", path, "POST")
198
+ if validate and not is_hostable(mode_id, map_value):
199
+ mode_label = found_mode.name if found_mode else f"Game mode {mode_id}"
200
+ map_label = map_name(map_value) or f"map {map_value}"
201
+ raise local_error(
202
+ "bad_request",
203
+ f"{mode_label} cannot be hosted on {map_label}. Pass validate=False if the API has added it since this release.",
204
+ path,
205
+ "POST",
206
+ )
207
+ if not room_name.strip():
208
+ raise local_error("bad_request", "room_name must not be empty.", path, "POST")
209
+ return {"server_id": server_id, "room_name": room_name, "password": password or "", "map_id": map_value, "game_mode_id": mode_id}
210
+
211
+
212
+ def check_command(command: str, user_id: Optional[int]) -> None:
213
+ if command not in ("kick", "restart", "room_owner_add", "room_owner_remove", "close"):
214
+ raise local_error("bad_request", f"Unknown command {command!r}.", "/autohost/control", "POST")
215
+ if command in ("kick", "room_owner_add", "room_owner_remove") and user_id is None:
216
+ raise local_error("bad_request", f"{command} needs a user id.", "/autohost/control", "POST")
copsapi/_io_async.py ADDED
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ from typing import Any, Awaitable, Callable, Optional, TypeVar
6
+
7
+ import httpx
8
+
9
+ T = TypeVar("T")
10
+
11
+ HttpClient = httpx.AsyncClient
12
+
13
+
14
+ async def sleep(seconds: float) -> None:
15
+ await asyncio.sleep(max(0.0, seconds))
16
+
17
+
18
+ async def resolve(value: Any) -> Any:
19
+ return await value if inspect.isawaitable(value) else value
20
+
21
+
22
+ class Gate:
23
+ def __init__(self, max_concurrent: int, dedupe: bool) -> None:
24
+ self._semaphore = asyncio.Semaphore(max(1, max_concurrent))
25
+ self._dedupe = dedupe
26
+ self._inflight: dict[str, asyncio.Future[Any]] = {}
27
+
28
+ async def _limited(self, fn: Callable[[], Awaitable[T]]) -> T:
29
+ async with self._semaphore:
30
+ return await fn()
31
+
32
+ async def run(self, key: Optional[str], fn: Callable[[], Awaitable[T]]) -> T:
33
+ if key is None or not self._dedupe:
34
+ return await self._limited(fn)
35
+ pending = self._inflight.get(key)
36
+ if pending is not None:
37
+ return await asyncio.shield(pending)
38
+ future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
39
+ self._inflight[key] = future
40
+ try:
41
+ result = await self._limited(fn)
42
+ except BaseException as err:
43
+ future.set_exception(err)
44
+ future.exception()
45
+ raise
46
+ else:
47
+ future.set_result(result)
48
+ return result
49
+ finally:
50
+ self._inflight.pop(key, None)
copsapi/_io_sync.py ADDED
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any, Callable, Optional, TypeVar
5
+
6
+ import httpx
7
+
8
+ T = TypeVar("T")
9
+
10
+ HttpClient = httpx.Client
11
+
12
+
13
+ def sleep(seconds: float) -> None:
14
+ time.sleep(max(0.0, seconds))
15
+
16
+
17
+ def resolve(value: Any) -> Any:
18
+ return value
19
+
20
+
21
+ class Gate:
22
+ def __init__(self, max_concurrent: int, dedupe: bool) -> None:
23
+ pass
24
+
25
+ def run(self, key: Optional[str], fn: Callable[[], T]) -> T:
26
+ return fn()