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