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/cache.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from collections import OrderedDict
|
|
6
|
+
from typing import Any, Callable, Optional, Protocol
|
|
7
|
+
|
|
8
|
+
MISSING: Any = object()
|
|
9
|
+
|
|
10
|
+
DEFAULT_TTLS: dict[str, float] = {
|
|
11
|
+
"profile": 300.0,
|
|
12
|
+
"servers": 30.0,
|
|
13
|
+
"leaderboard": 60.0,
|
|
14
|
+
"history": 60.0,
|
|
15
|
+
"match": 3600.0,
|
|
16
|
+
"weapons": 120.0,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CacheStore(Protocol):
|
|
21
|
+
def get(self, key: str) -> Any: ...
|
|
22
|
+
|
|
23
|
+
def set(self, key: str, value: Any, ttl: float) -> Any: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MemoryCache:
|
|
27
|
+
def __init__(self, max_entries: int = 5000, clock: Callable[[], float] = time.monotonic) -> None:
|
|
28
|
+
self._entries: OrderedDict[str, tuple[float, Any]] = OrderedDict()
|
|
29
|
+
self._max = max_entries
|
|
30
|
+
self._clock = clock
|
|
31
|
+
self._lock = threading.Lock()
|
|
32
|
+
|
|
33
|
+
def get(self, key: str) -> Any:
|
|
34
|
+
with self._lock:
|
|
35
|
+
hit = self._entries.get(key)
|
|
36
|
+
if hit is None:
|
|
37
|
+
return None
|
|
38
|
+
expires, value = hit
|
|
39
|
+
if expires <= self._clock():
|
|
40
|
+
del self._entries[key]
|
|
41
|
+
return None
|
|
42
|
+
self._entries.move_to_end(key)
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
def set(self, key: str, value: Any, ttl: float) -> None:
|
|
46
|
+
if ttl <= 0:
|
|
47
|
+
return
|
|
48
|
+
with self._lock:
|
|
49
|
+
self._entries[key] = (self._clock() + ttl, value)
|
|
50
|
+
self._entries.move_to_end(key)
|
|
51
|
+
while len(self._entries) > self._max:
|
|
52
|
+
self._entries.popitem(last=False)
|
|
53
|
+
|
|
54
|
+
def delete(self, key: str) -> None:
|
|
55
|
+
with self._lock:
|
|
56
|
+
self._entries.pop(key, None)
|
|
57
|
+
|
|
58
|
+
def clear(self) -> None:
|
|
59
|
+
with self._lock:
|
|
60
|
+
self._entries.clear()
|
|
61
|
+
|
|
62
|
+
def __len__(self) -> int:
|
|
63
|
+
return len(self._entries)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def resolve_ttls(overrides: Optional[dict[str, float]]) -> dict[str, float]:
|
|
67
|
+
ttls = dict(DEFAULT_TTLS)
|
|
68
|
+
if overrides:
|
|
69
|
+
unknown = set(overrides) - set(ttls)
|
|
70
|
+
if unknown:
|
|
71
|
+
raise ValueError(f"Unknown cache TTL keys: {', '.join(sorted(unknown))}")
|
|
72
|
+
ttls.update(overrides)
|
|
73
|
+
return ttls
|
copsapi/catalog.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional, Union
|
|
6
|
+
|
|
7
|
+
MAPS: dict[int, str] = {
|
|
8
|
+
613: "Canals",
|
|
9
|
+
615: "Plaza",
|
|
10
|
+
617: "Legacy",
|
|
11
|
+
618: "Grounded",
|
|
12
|
+
619: "Bureau",
|
|
13
|
+
839: "Brewery",
|
|
14
|
+
841: "Division",
|
|
15
|
+
1311: "Uplink",
|
|
16
|
+
1312: "Yard",
|
|
17
|
+
1360: "Raid",
|
|
18
|
+
4483: "Heat",
|
|
19
|
+
6655: "Warehouse",
|
|
20
|
+
6670: "Gallery",
|
|
21
|
+
6677: "Port",
|
|
22
|
+
7201: "Park",
|
|
23
|
+
7316: "Purify",
|
|
24
|
+
7554: "Cargo",
|
|
25
|
+
7584: "Arctic",
|
|
26
|
+
8024: "Bout",
|
|
27
|
+
8427: "Soar",
|
|
28
|
+
8872: "Shanty",
|
|
29
|
+
9310: "Hangar",
|
|
30
|
+
13410: "Village",
|
|
31
|
+
14066: "Impasse",
|
|
32
|
+
15028: "Overhaul",
|
|
33
|
+
15391: "Castello",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class GameMode:
|
|
39
|
+
id: int
|
|
40
|
+
key: str
|
|
41
|
+
name: str
|
|
42
|
+
maps: tuple[int, ...]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_ALL = (613, 615, 617, 618, 619, 839, 841, 1311, 1312, 1360, 4483, 6655, 6670, 6677, 7201, 7316, 7554, 7584, 8024, 8427, 8872, 9310, 13410, 14066, 15028, 15391)
|
|
46
|
+
|
|
47
|
+
GAME_MODES: dict[str, GameMode] = {
|
|
48
|
+
m.key: m
|
|
49
|
+
for m in (
|
|
50
|
+
GameMode(15773, "defuse", "Defuse", (613, 615, 617, 618, 619, 1311, 1312, 1360, 6677, 7201, 7316, 7584, 8024, 8427, 8872, 13410, 15391)),
|
|
51
|
+
GameMode(15775, "elimination", "Elimination", (6655, 14066, 15028)),
|
|
52
|
+
GameMode(15776, "gungame", "Gungame", (839, 841, 1311, 1312, 4483, 6655, 6670, 7201, 7554, 7584, 8024, 9310, 14066, 15028)),
|
|
53
|
+
GameMode(15777, "tdm", "Team Deathmatch", _ALL),
|
|
54
|
+
GameMode(15846, "manhunt", "Manhunt", (7316,)),
|
|
55
|
+
GameMode(15847, "tournament", "Tournament Mode", (613, 615, 617, 618, 619, 1360, 6677, 8427, 13410, 15391)),
|
|
56
|
+
GameMode(15860, "rush", "Rush", _ALL),
|
|
57
|
+
GameMode(15861, "defend", "Defend", (8872,)),
|
|
58
|
+
GameMode(18596, "manhuntz", "Manhunt Z", (7316,)),
|
|
59
|
+
GameMode(18850, "wingman", "Wingman", (613, 615, 617, 619, 1360, 13410, 15391)),
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_MODE_BY_ID = {m.id: m for m in GAME_MODES.values()}
|
|
64
|
+
_MAP_BY_NAME = {name.lower(): map_id for map_id, name in MAPS.items()}
|
|
65
|
+
_ALIASES = {"teamdeathmatch": "tdm", "tournamentmode": "tournament"}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _squash(text: str) -> str:
|
|
69
|
+
return re.sub(r"[\s_-]+", "", text.strip().lower())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def map_id(map_: Union[int, str]) -> Optional[int]:
|
|
73
|
+
if isinstance(map_, int):
|
|
74
|
+
return map_ if map_ in MAPS else None
|
|
75
|
+
text = map_.strip()
|
|
76
|
+
if text.isdigit():
|
|
77
|
+
return map_id(int(text))
|
|
78
|
+
return _MAP_BY_NAME.get(text.lower())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def map_name(id_: int) -> Optional[str]:
|
|
82
|
+
return MAPS.get(id_)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def game_mode(mode: Union[int, str]) -> Optional[GameMode]:
|
|
86
|
+
if isinstance(mode, int):
|
|
87
|
+
return _MODE_BY_ID.get(mode)
|
|
88
|
+
key = _squash(mode)
|
|
89
|
+
if key.isdigit():
|
|
90
|
+
return game_mode(int(key))
|
|
91
|
+
key = _ALIASES.get(key, key)
|
|
92
|
+
for m in GAME_MODES.values():
|
|
93
|
+
if m.key == key or _squash(m.name) == key:
|
|
94
|
+
return m
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def maps_for(mode: Union[int, str]) -> list[int]:
|
|
99
|
+
m = game_mode(mode)
|
|
100
|
+
return list(m.maps) if m else []
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def modes_for(map_: Union[int, str]) -> list[GameMode]:
|
|
104
|
+
mid = map_id(map_)
|
|
105
|
+
if mid is None:
|
|
106
|
+
return []
|
|
107
|
+
return [m for m in GAME_MODES.values() if mid in m.maps]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_hostable(mode: Union[int, str], map_: Union[int, str]) -> bool:
|
|
111
|
+
m = game_mode(mode)
|
|
112
|
+
mid = map_id(map_)
|
|
113
|
+
return m is not None and mid is not None and mid in m.maps
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
BAN_REASONS: dict[int, str] = {
|
|
117
|
+
0: "Unknown",
|
|
118
|
+
1: "Fraud attempt",
|
|
119
|
+
2: "Hacking",
|
|
120
|
+
3: "Griefing",
|
|
121
|
+
4: "Verbal abuse",
|
|
122
|
+
5: "Idle",
|
|
123
|
+
6: "Spam",
|
|
124
|
+
7: "Report abuse",
|
|
125
|
+
8: "Account trading",
|
|
126
|
+
9: "Glitching",
|
|
127
|
+
10: "Ghosting",
|
|
128
|
+
11: "Partying with hacker",
|
|
129
|
+
12: "Under investigation",
|
|
130
|
+
13: "Marketplace abuse",
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
USER_TYPES: dict[int, str] = {0: "Spectator", 1: "User", 2: "Assistant mod", 4: "Moderator", 8: "Veteran mod", 64: "Developer"}
|
|
134
|
+
|
|
135
|
+
ACCOUNT_AGES: dict[str, str] = {
|
|
136
|
+
"1w": "Under a week",
|
|
137
|
+
"1m": "Under a month",
|
|
138
|
+
"3m": "Under 3 months",
|
|
139
|
+
"1y": "Under a year",
|
|
140
|
+
"2y": "1 to 2 years",
|
|
141
|
+
"3y": "2 to 3 years",
|
|
142
|
+
"4y": "3 to 4 years",
|
|
143
|
+
"5y+": "4 years or more",
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
PARTY_INVITE_PERMISSIONS: dict[int, str] = {0: "Anybody", 1: "Only friends and clan members", 2: "Nobody"}
|
|
147
|
+
|
|
148
|
+
CLAN_RANKS: dict[int, str] = {10: "Member", 20: "Officer", 30: "Co-leader", 40: "Leader"}
|
|
149
|
+
|
|
150
|
+
GAME_TYPES: dict[str, int] = {"ranked": 1, "casual": 2}
|
|
151
|
+
|
|
152
|
+
LEADERBOARD_TYPES: tuple[str, ...] = ("mmr", "kills", "deaths", "assists", "matches", "wins", "losses", "xp", "mvp")
|
|
153
|
+
|
|
154
|
+
MATCH_STATES: tuple[str, ...] = ("WaitingForPlayers", "Starting", "Freezetime", "OnGoing", "BombPlanted", "Win", "TeamFlip", "GameEnd", "ChangingMap")
|
|
155
|
+
|
|
156
|
+
INPUT_DEVICES: dict[str, int] = {"keyboard_mouse": 1, "touch": 2, "gamepad": 4}
|
|
157
|
+
|
|
158
|
+
AUTOHOST_COMMANDS: tuple[str, ...] = ("kick", "restart", "room_owner_add", "room_owner_remove", "close")
|
copsapi/cli.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import dataclasses
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Optional, Sequence
|
|
9
|
+
|
|
10
|
+
from ._core import VERSION
|
|
11
|
+
from ._sync_client import CopsClient
|
|
12
|
+
from .catalog import AUTOHOST_COMMANDS, GAME_MODES, LEADERBOARD_TYPES, MAPS, map_name
|
|
13
|
+
from .errors import CopsApiError
|
|
14
|
+
from .helpers import career_totals, describe_ban, input_devices, summarize_result
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _jsonable(value: Any) -> Any:
|
|
18
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
19
|
+
return {f.name: _jsonable(getattr(value, f.name)) for f in dataclasses.fields(value) if f.name != "raw"}
|
|
20
|
+
if isinstance(value, (list, tuple)):
|
|
21
|
+
return [_jsonable(v) for v in value]
|
|
22
|
+
if isinstance(value, dict):
|
|
23
|
+
return {str(k): _jsonable(v) for k, v in value.items()}
|
|
24
|
+
if isinstance(value, BaseException):
|
|
25
|
+
return {"code": getattr(value, "code", None), "message": str(value)}
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _print_json(value: Any) -> None:
|
|
30
|
+
print(json.dumps(_jsonable(value), indent=2, ensure_ascii=False))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _ref(text: str) -> Any:
|
|
34
|
+
return int(text) if text.isdigit() else text
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parser() -> argparse.ArgumentParser:
|
|
38
|
+
parser = argparse.ArgumentParser(prog="copsapi", description=f"copsapi {VERSION}: the Critical Ops Public API v2 from the command line")
|
|
39
|
+
parser.add_argument("--token", help="your API token (default: COPS_API_TOKEN)")
|
|
40
|
+
parser.add_argument("--base", help="API base URL (default: COPS_API_BASE or the production API)")
|
|
41
|
+
parser.add_argument("--json", action="store_true", help="print raw JSON")
|
|
42
|
+
parser.add_argument("--version", action="version", version=f"copsapi {VERSION}")
|
|
43
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
44
|
+
|
|
45
|
+
sub.add_parser("token", help="check your token and whether it can host games")
|
|
46
|
+
p = sub.add_parser("profile", help="player profiles")
|
|
47
|
+
p.add_argument("players", nargs="+", help="user ids or usernames")
|
|
48
|
+
sub.add_parser("servers", help="game servers and their status")
|
|
49
|
+
p = sub.add_parser("history", help="a player's recent matches")
|
|
50
|
+
p.add_argument("user_id", type=int)
|
|
51
|
+
p = sub.add_parser("match", help="full scoreboards of matches")
|
|
52
|
+
p.add_argument("match_ids", nargs="+")
|
|
53
|
+
p = sub.add_parser("weapons", help="a player's weapon totals")
|
|
54
|
+
p.add_argument("user_id", type=int)
|
|
55
|
+
p.add_argument("game_type", nargs="?", default="ranked", choices=["ranked", "casual"])
|
|
56
|
+
p = sub.add_parser("leaderboard", help="a leaderboard page")
|
|
57
|
+
p.add_argument("type", nargs="?", default="mmr", choices=LEADERBOARD_TYPES)
|
|
58
|
+
p.add_argument("page", nargs="?", type=int, default=1)
|
|
59
|
+
p.add_argument("--rank", type=int, choices=range(10), metavar="0-9")
|
|
60
|
+
p = sub.add_parser("host", help="host a custom game and wait for its room code")
|
|
61
|
+
p.add_argument("--server", type=int, required=True)
|
|
62
|
+
p.add_argument("--map", required=True)
|
|
63
|
+
p.add_argument("--mode", required=True)
|
|
64
|
+
p.add_argument("--name", default="Custom game")
|
|
65
|
+
p.add_argument("--password")
|
|
66
|
+
p.add_argument("--no-password", action="store_true")
|
|
67
|
+
p.add_argument("--owner", type=int, action="append", default=[])
|
|
68
|
+
p = sub.add_parser("poll", help="hosted games")
|
|
69
|
+
p.add_argument("game_ids", nargs="+", type=int)
|
|
70
|
+
p = sub.add_parser("control", help="send a command to a hosted game")
|
|
71
|
+
p.add_argument("game_id", type=int)
|
|
72
|
+
p.add_argument("action", choices=AUTOHOST_COMMANDS)
|
|
73
|
+
p.add_argument("user_id", nargs="?", type=int)
|
|
74
|
+
p = sub.add_parser("watch", help="follow hosted games live")
|
|
75
|
+
p.add_argument("game_ids", nargs="+", type=int)
|
|
76
|
+
sub.add_parser("catalog", help="hostable game modes and maps")
|
|
77
|
+
sub.add_parser("quota", help="requests left this minute")
|
|
78
|
+
return parser
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _run(args: argparse.Namespace) -> None:
|
|
82
|
+
if args.command == "catalog":
|
|
83
|
+
if args.json:
|
|
84
|
+
_print_json({"maps": MAPS, "game_modes": {k: dataclasses.asdict(m) for k, m in GAME_MODES.items()}})
|
|
85
|
+
return
|
|
86
|
+
for mode in GAME_MODES.values():
|
|
87
|
+
print(f"{mode.name} ({mode.key}, {mode.id}): {', '.join(map_name(m) or str(m) for m in mode.maps)}")
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
with CopsClient(args.token, base_url=args.base) as cops:
|
|
91
|
+
if args.command == "token":
|
|
92
|
+
check = cops.verify_token()
|
|
93
|
+
if args.json:
|
|
94
|
+
_print_json(check)
|
|
95
|
+
elif not check.valid:
|
|
96
|
+
print(f"Token rejected: {check.error}")
|
|
97
|
+
else:
|
|
98
|
+
can = "yes, you can host games" if check.autohost else "no, hosting commands will not work"
|
|
99
|
+
print(f"Token valid. Autohost permission: {can}. Requests left this minute: {check.quota.remaining}.")
|
|
100
|
+
elif args.command == "profile":
|
|
101
|
+
refs = [_ref(p) for p in args.players]
|
|
102
|
+
profiles = cops.profiles.many(refs)
|
|
103
|
+
if args.json:
|
|
104
|
+
_print_json(profiles)
|
|
105
|
+
return
|
|
106
|
+
for p in profiles:
|
|
107
|
+
totals = career_totals(p)
|
|
108
|
+
ranked = p.stats.ranked
|
|
109
|
+
clan = f" [{p.clan.basic_info.tag}]" if p.clan else ""
|
|
110
|
+
print(f"{p.name} ({p.user_id}){clan}")
|
|
111
|
+
ban = f", {describe_ban(p.ban)}" if p.ban else ""
|
|
112
|
+
print(f" Level {p.basic_info.player_level.level}, account {p.account_age}{ban}")
|
|
113
|
+
placements = f", {ranked.placement_matches_left} placements left" if ranked.placement_matches_left else ""
|
|
114
|
+
print(f" Ranked: rank {ranked.rank}, {ranked.mmr} MMR, {ranked.wins}W {ranked.losses}L{placements}")
|
|
115
|
+
print(f" Career: {totals.kills} kills, K/D {totals.kd}, {totals.matches} matches, {round(totals.win_rate * 100)}% won")
|
|
116
|
+
if len(profiles) < len(refs):
|
|
117
|
+
print(f"{len(refs) - len(profiles)} player(s) not found.")
|
|
118
|
+
elif args.command == "servers":
|
|
119
|
+
servers = cops.servers.list()
|
|
120
|
+
if args.json:
|
|
121
|
+
_print_json(servers)
|
|
122
|
+
return
|
|
123
|
+
for s in servers:
|
|
124
|
+
print(f"{s.id:>5} {'online ' if s.online else 'offline'} {s.name}{f' ({s.region})' if s.region else ''}")
|
|
125
|
+
elif args.command == "history":
|
|
126
|
+
history = cops.matches.history(args.user_id)
|
|
127
|
+
if args.json:
|
|
128
|
+
_print_json(history)
|
|
129
|
+
return
|
|
130
|
+
for h in history:
|
|
131
|
+
result = {1: "W", 2: "L", 3: "D"}.get(h.result, "?")
|
|
132
|
+
kind = "ranked" if h.game_type == 1 else "casual"
|
|
133
|
+
left = " (left)" if h.left_match else ""
|
|
134
|
+
print(f"{result} {kind} {str(map_name(h.map_id) or h.map_id):<10} {'-'.join(map(str, h.scores))}{left} {h.id}")
|
|
135
|
+
elif args.command == "match":
|
|
136
|
+
matches = cops.matches.get(args.match_ids)
|
|
137
|
+
if args.json:
|
|
138
|
+
_print_json(matches)
|
|
139
|
+
return
|
|
140
|
+
for m in matches:
|
|
141
|
+
summary = summarize_result(m)
|
|
142
|
+
outcome = "draw" if summary.draw else f"team {summary.winning_team} won"
|
|
143
|
+
print(f"{summary.map or m.map_id}: {'-'.join(map(str, m.scores))}, {outcome} by {summary.win_condition} in {round(summary.duration / 60)} min")
|
|
144
|
+
for team in summary.teams:
|
|
145
|
+
print(f" Team {team.team}{' (won)' if team.won else ''}")
|
|
146
|
+
for p in team.players:
|
|
147
|
+
left = f" left: {p.leave_reason}" if p.left_match else ""
|
|
148
|
+
print(f" {p.user_id:<10} {p.kills}/{p.deaths}/{p.assists} {p.damage_dealt} dmg {'+'.join(input_devices(p.input_device_flags))}{left}")
|
|
149
|
+
elif args.command == "weapons":
|
|
150
|
+
weapons = cops.stats.weapons(args.user_id, args.game_type)
|
|
151
|
+
if args.json:
|
|
152
|
+
_print_json(weapons)
|
|
153
|
+
return
|
|
154
|
+
for w in sorted(weapons, key=lambda w: w.k, reverse=True):
|
|
155
|
+
print(f"{w.name:<16} {w.k:>7} kills {w.h:>7} headshots {w.a:>6} assists")
|
|
156
|
+
elif args.command == "leaderboard":
|
|
157
|
+
page = cops.leaderboards.page(args.type, page=args.page, rank=args.rank)
|
|
158
|
+
if args.json:
|
|
159
|
+
_print_json(page)
|
|
160
|
+
return
|
|
161
|
+
print(f"{page.type} leaderboard, page {page.page} of {page.pages} ({page.size} players)")
|
|
162
|
+
for e in page.entries:
|
|
163
|
+
clan = f" [{e.clan.basic_info.tag}]" if e.clan else ""
|
|
164
|
+
print(f"{e.position:>6} {e.score:>8} {e.basic_info.name}{clan}")
|
|
165
|
+
elif args.command == "host":
|
|
166
|
+
password: Any = False if args.no_password else args.password
|
|
167
|
+
room = cops.autohost.host(
|
|
168
|
+
server_id=args.server,
|
|
169
|
+
map=_ref(args.map),
|
|
170
|
+
mode=_ref(args.mode),
|
|
171
|
+
room_name=args.name,
|
|
172
|
+
password=password,
|
|
173
|
+
owners=args.owner,
|
|
174
|
+
)
|
|
175
|
+
if args.json:
|
|
176
|
+
_print_json(room)
|
|
177
|
+
return
|
|
178
|
+
pw = f", password: {room.password}" if room.password else ""
|
|
179
|
+
print(f"Game {room.id} is up. Room code: {room.code}{pw}")
|
|
180
|
+
elif args.command == "poll":
|
|
181
|
+
_print_json(cops.autohost.poll(args.game_ids))
|
|
182
|
+
elif args.command == "control":
|
|
183
|
+
game = cops.autohost.control(args.game_id, args.action, args.user_id)
|
|
184
|
+
if args.json:
|
|
185
|
+
_print_json(game)
|
|
186
|
+
else:
|
|
187
|
+
print(f"Sent {args.action} to game {game.id}.")
|
|
188
|
+
elif args.command == "watch":
|
|
189
|
+
_watch(cops, args.game_ids)
|
|
190
|
+
elif args.command == "quota":
|
|
191
|
+
cops.servers.list(fresh=True)
|
|
192
|
+
_print_json(cops.quota)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _watch(cops: CopsClient, game_ids: Sequence[int]) -> None:
|
|
196
|
+
watcher = cops.autohost.watch()
|
|
197
|
+
for game_id in game_ids:
|
|
198
|
+
watcher.track(game_id)
|
|
199
|
+
|
|
200
|
+
def log(game_id: int, text: str) -> None:
|
|
201
|
+
print(f"{time.strftime('%H:%M:%S')} #{game_id} {text}", flush=True)
|
|
202
|
+
|
|
203
|
+
watcher.on("room_ready", lambda g: log(g.id, f"room ready, code {g.room.code}"))
|
|
204
|
+
watcher.on("state", lambda g, s, _prev: log(g.id, f"state {s}"))
|
|
205
|
+
watcher.on("score", lambda g, rnd, scores: log(g.id, f"round {rnd}, {'-'.join(map(str, scores))}"))
|
|
206
|
+
watcher.on(
|
|
207
|
+
"player_join",
|
|
208
|
+
lambda g, p, j: log(g.id, f"{p.user_id} joined" + (f" after {round(j.since_room_ready)}s" if j.since_room_ready is not None else "")),
|
|
209
|
+
)
|
|
210
|
+
watcher.on("player_leave", lambda g, p: log(g.id, f"{p.user_id} left"))
|
|
211
|
+
watcher.on("player_disconnect", lambda g, p: log(g.id, f"{p.user_id} disconnected"))
|
|
212
|
+
watcher.on("player_reconnect", lambda g, p, secs: log(g.id, f"{p.user_id} reconnected after {round(secs)}s"))
|
|
213
|
+
watcher.on(
|
|
214
|
+
"match_result",
|
|
215
|
+
lambda g, r, _i: log(g.id, f"match over on {map_name(r.map_id) or r.map_id}: {'-'.join(map(str, r.scores))}, winner team {r.winning_team}"),
|
|
216
|
+
)
|
|
217
|
+
watcher.on("rejected", lambda g, reason: log(g.id, f"refused by the game server: {reason or 'no reason given'}"))
|
|
218
|
+
watcher.on("ended", lambda g: log(g.id, "ended"))
|
|
219
|
+
watcher.on("gone", lambda game_id, _last: log(game_id, "gone"))
|
|
220
|
+
watcher.on("poll_error", lambda err: print(f"{time.strftime('%H:%M:%S')} poll failed: {err}", file=sys.stderr, flush=True))
|
|
221
|
+
try:
|
|
222
|
+
watcher.run()
|
|
223
|
+
except KeyboardInterrupt:
|
|
224
|
+
watcher.stop()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
228
|
+
args = _parser().parse_args(argv)
|
|
229
|
+
try:
|
|
230
|
+
_run(args)
|
|
231
|
+
except CopsApiError as err:
|
|
232
|
+
status = f", HTTP {err.status}" if err.status else ""
|
|
233
|
+
print(f"Error ({err.code}{status}): {err}", file=sys.stderr)
|
|
234
|
+
return 1
|
|
235
|
+
except ValueError as err:
|
|
236
|
+
print(f"Error: {err}", file=sys.stderr)
|
|
237
|
+
return 2
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
if __name__ == "__main__":
|
|
242
|
+
raise SystemExit(main())
|
copsapi/errors.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
API_CODES = frozenset(
|
|
6
|
+
{
|
|
7
|
+
"missing_token",
|
|
8
|
+
"invalid_token",
|
|
9
|
+
"token_revoked",
|
|
10
|
+
"insufficient_permissions",
|
|
11
|
+
"rate_limited",
|
|
12
|
+
"bad_request",
|
|
13
|
+
"user_not_found",
|
|
14
|
+
"game_not_found",
|
|
15
|
+
"server_unavailable",
|
|
16
|
+
"api_disabled",
|
|
17
|
+
"internal_error",
|
|
18
|
+
}
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
RETRYABLE = frozenset({"rate_limited", "server_unavailable", "api_disabled", "internal_error", "network", "timeout", "gateway"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CopsApiError(Exception):
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
code: str,
|
|
28
|
+
message: str,
|
|
29
|
+
status: int = 0,
|
|
30
|
+
retry_after: Optional[float] = None,
|
|
31
|
+
*,
|
|
32
|
+
method: Optional[str] = None,
|
|
33
|
+
path: Optional[str] = None,
|
|
34
|
+
local: bool = False,
|
|
35
|
+
) -> None:
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.code = code
|
|
38
|
+
self.message = message
|
|
39
|
+
self.status = status
|
|
40
|
+
self.retry_after = retry_after
|
|
41
|
+
self.method = method
|
|
42
|
+
self.path = path
|
|
43
|
+
self.local = local
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def retryable(self) -> bool:
|
|
47
|
+
return self.code in RETRYABLE
|
|
48
|
+
|
|
49
|
+
def __repr__(self) -> str:
|
|
50
|
+
return f"{type(self).__name__}(code={self.code!r}, status={self.status}, message={self.message!r})"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AuthenticationError(CopsApiError):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PermissionDeniedError(CopsApiError):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RateLimitError(CopsApiError):
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class BadRequestError(CopsApiError):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class NotFoundError(CopsApiError):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ServiceUnavailableError(CopsApiError):
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ConnectionFailedError(CopsApiError):
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class InvalidResponseError(CopsApiError):
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class GameRejectedError(CopsApiError):
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class GameGoneError(CopsApiError):
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
_CLASSES: dict[str, type[CopsApiError]] = {
|
|
94
|
+
"missing_token": AuthenticationError,
|
|
95
|
+
"invalid_token": AuthenticationError,
|
|
96
|
+
"token_revoked": AuthenticationError,
|
|
97
|
+
"insufficient_permissions": PermissionDeniedError,
|
|
98
|
+
"rate_limited": RateLimitError,
|
|
99
|
+
"bad_request": BadRequestError,
|
|
100
|
+
"user_not_found": NotFoundError,
|
|
101
|
+
"game_not_found": NotFoundError,
|
|
102
|
+
"server_unavailable": ServiceUnavailableError,
|
|
103
|
+
"api_disabled": ServiceUnavailableError,
|
|
104
|
+
"internal_error": ServiceUnavailableError,
|
|
105
|
+
"gateway": ServiceUnavailableError,
|
|
106
|
+
"network": ConnectionFailedError,
|
|
107
|
+
"timeout": ConnectionFailedError,
|
|
108
|
+
"invalid_response": InvalidResponseError,
|
|
109
|
+
"game_rejected": GameRejectedError,
|
|
110
|
+
"game_gone": GameGoneError,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def make_error(
|
|
115
|
+
code: str,
|
|
116
|
+
message: str,
|
|
117
|
+
status: int = 0,
|
|
118
|
+
retry_after: Optional[float] = None,
|
|
119
|
+
*,
|
|
120
|
+
method: Optional[str] = None,
|
|
121
|
+
path: Optional[str] = None,
|
|
122
|
+
local: bool = False,
|
|
123
|
+
) -> CopsApiError:
|
|
124
|
+
cls = _CLASSES.get(code, CopsApiError)
|
|
125
|
+
return cls(code, message, status, retry_after, method=method, path=path, local=local)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def is_cops_error(err: object, *codes: str) -> bool:
|
|
129
|
+
return isinstance(err, CopsApiError) and (not codes or err.code in codes)
|