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/helpers.py ADDED
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import secrets
5
+ from dataclasses import dataclass
6
+ from typing import Any, Generic, Optional, TypeVar, Union
7
+
8
+ from .catalog import ACCOUNT_AGES, BAN_REASONS, INPUT_DEVICES, map_name
9
+ from .models import AutohostResult, Ban, MatchInfo, MatchPlayer, MatchStats, PlayerMatchStats, Profile
10
+
11
+
12
+ def input_devices(flags: int) -> list[str]:
13
+ return [name for name, bit in INPUT_DEVICES.items() if flags & bit]
14
+
15
+
16
+ def ban_reason(ban: Optional[Ban]) -> Optional[str]:
17
+ if ban is None:
18
+ return None
19
+ return BAN_REASONS.get(ban.reason, f"Reason {ban.reason}")
20
+
21
+
22
+ def describe_ban(ban: Optional[Ban]) -> Optional[str]:
23
+ if ban is None:
24
+ return None
25
+ reason = ban_reason(ban)
26
+ if ban.type == 0:
27
+ return f"Permanently banned ({reason})"
28
+ if ban.days_left == 0:
29
+ return f"Banned with no end date ({reason})"
30
+ return f"Banned for {ban.days_left} more day{'' if ban.days_left == 1 else 's'} ({reason})"
31
+
32
+
33
+ def account_age_label(profile: Profile) -> str:
34
+ return ACCOUNT_AGES.get(profile.account_age, profile.account_age)
35
+
36
+
37
+ def is_new_account(profile: Profile) -> bool:
38
+ return profile.account_age in ("1w", "1m")
39
+
40
+
41
+ def is_staff(user_type: int) -> bool:
42
+ return user_type >= 2
43
+
44
+
45
+ def kd(kills: int, deaths: int, digits: int = 2) -> float:
46
+ return round(kills if deaths == 0 else kills / deaths, digits)
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Totals:
51
+ kills: int
52
+ deaths: int
53
+ assists: int
54
+ wins: int
55
+ losses: int
56
+ matches: int
57
+ kd: float
58
+ win_rate: float
59
+
60
+
61
+ _SCOPES = ("ranked", "casual", "custom")
62
+
63
+
64
+ def _totals(rows: list[MatchStats]) -> Totals:
65
+ k = sum(r.k for r in rows)
66
+ d = sum(r.d for r in rows)
67
+ a = sum(r.a for r in rows)
68
+ w = sum(r.w for r in rows)
69
+ l_ = sum(r.l for r in rows)
70
+ matches = w + l_
71
+ return Totals(k, d, a, w, l_, matches, kd(k, d), round(w / matches, 3) if matches else 0.0)
72
+
73
+
74
+ def _pick(scope: str) -> tuple[str, ...]:
75
+ if scope == "all":
76
+ return _SCOPES
77
+ if scope not in _SCOPES:
78
+ raise ValueError("scope must be 'ranked', 'casual', 'custom' or 'all'")
79
+ return (scope,)
80
+
81
+
82
+ def career_totals(profile: Profile, scope: str = "all") -> Totals:
83
+ keys = _pick(scope)
84
+ return _totals([getattr(season, key) for season in profile.stats.seasonal_stats for key in keys])
85
+
86
+
87
+ def season_totals(profile: Profile, season: Optional[int] = None, scope: str = "all") -> Optional[Totals]:
88
+ seasons = profile.stats.seasonal_stats
89
+ if not seasons:
90
+ return None
91
+ entry = seasons[-1] if season is None else next((s for s in seasons if s.season == season), None)
92
+ if entry is None:
93
+ return None
94
+ return _totals([getattr(entry, key) for key in _pick(scope)])
95
+
96
+
97
+ P = TypeVar("P", PlayerMatchStats, MatchPlayer)
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class TeamSummary(Generic[P]):
102
+ team: int
103
+ score: int
104
+ faction: Optional[int]
105
+ won: bool
106
+ players: list[P]
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class ResultSummary(Generic[P]):
111
+ map: Optional[str]
112
+ winning_team: Optional[int]
113
+ draw: bool
114
+ win_condition: str
115
+ duration: int
116
+ teams: list[TeamSummary[P]]
117
+ top_fragger: Optional[P]
118
+ leavers: list[P]
119
+
120
+
121
+ def summarize_result(result: Union[AutohostResult, MatchInfo]) -> ResultSummary:
122
+ players: list[Any] = list(result.players)
123
+ winner = result.winning_team if result.winning_team >= 0 else None
124
+ team_count = max([len(result.scores), *(p.team + 1 for p in players), 0])
125
+
126
+ def order(p):
127
+ return (-p.kills, p.deaths, -p.damage_dealt)
128
+
129
+ teams = [
130
+ TeamSummary(
131
+ team=t,
132
+ score=result.scores[t] if t < len(result.scores) else 0,
133
+ faction=result.team_factions[t] if t < len(result.team_factions) else None,
134
+ won=winner == t,
135
+ players=sorted((p for p in players if p.team == t), key=order),
136
+ )
137
+ for t in range(team_count)
138
+ ]
139
+ ranked = sorted((p for p in players if p.team >= 0), key=order)
140
+ return ResultSummary(
141
+ map=map_name(result.map_id),
142
+ winning_team=winner,
143
+ draw=winner is None,
144
+ win_condition=result.win_condition,
145
+ duration=max(0, result.ended_at - result.started_at),
146
+ teams=teams,
147
+ top_fragger=ranked[0] if ranked else None,
148
+ leavers=[p for p in players if p.left_match],
149
+ )
150
+
151
+
152
+ PASSWORD_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
153
+
154
+
155
+ def random_password(length: int = 6, alphabet: str = PASSWORD_ALPHABET) -> str:
156
+ return "".join(secrets.choice(alphabet) for _ in range(length))
157
+
158
+
159
+ _REGIONS: list[tuple[str, re.Pattern[str]]] = [
160
+ ("EU", re.compile(r"^(eu\b|eu-|europe|de-|frankfurt|amsterdam|london|paris|stockholm|warsaw|madrid|finland|helsinki)", re.I)),
161
+ ("NA", re.compile(r"^(na\b|na-|us\b|us-|usa|north america|america|new york|chicago|dallas|seattle|los angeles|virginia|oregon|canada)", re.I)),
162
+ ("SA", re.compile(r"^(sa\b|sa-|br\b|br-|brazil|south america|sao paulo|são paulo|santiago|buenos aires)", re.I)),
163
+ ("AS", re.compile(r"^(as\b|as-|asia|sg\b|sg-|singapore|jp\b|jp-|japan|tokyo|hk\b|hong kong|kr\b|seoul|in\b|india|mumbai|sea\b)", re.I)),
164
+ ("OC", re.compile(r"^(oc\b|oc-|oceania|au\b|au-|australia|sydney)", re.I)),
165
+ ("ME", re.compile(r"^(me\b|me-|middle east|dubai|bahrain)", re.I)),
166
+ ("AF", re.compile(r"^(af\b|af-|africa|johannesburg)", re.I)),
167
+ ]
168
+
169
+
170
+ def region_of(server_name: str) -> Optional[str]:
171
+ name = server_name.strip()
172
+ for region, pattern in _REGIONS:
173
+ if pattern.search(name):
174
+ return region
175
+ return None
copsapi/models.py ADDED
@@ -0,0 +1,365 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import sys
5
+ import types
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timezone
8
+ from functools import lru_cache
9
+ from typing import Any, Literal, Optional, TypeVar, Union, cast, get_args, get_origin, get_type_hints
10
+
11
+ T = TypeVar("T")
12
+
13
+ _UNION_TYPES: tuple[Any, ...] = (Union, types.UnionType) if sys.version_info >= (3, 10) else (Union,)
14
+
15
+
16
+ @lru_cache(maxsize=None)
17
+ def _hints(cls: type) -> dict[str, Any]:
18
+ return get_type_hints(cls)
19
+
20
+
21
+ def _convert(tp: Any, value: Any) -> Any:
22
+ if value is None:
23
+ return None
24
+ origin = get_origin(tp)
25
+ if origin in _UNION_TYPES:
26
+ options = [a for a in get_args(tp) if a is not type(None)]
27
+ return _convert(options[0], value) if len(options) == 1 else value
28
+ if origin is Literal:
29
+ return value
30
+ if origin is list:
31
+ (item,) = get_args(tp) or (Any,)
32
+ return [_convert(item, v) for v in value] if isinstance(value, list) else []
33
+ if origin is dict:
34
+ return value if isinstance(value, dict) else {}
35
+ if isinstance(tp, type) and dataclasses.is_dataclass(tp):
36
+ return from_dict(tp, value) if isinstance(value, dict) else None
37
+ if tp is float and isinstance(value, int) and not isinstance(value, bool):
38
+ return float(value)
39
+ return value
40
+
41
+
42
+ def from_dict(cls: type[T], data: dict[str, Any]) -> T:
43
+ target = cast(Any, cls)
44
+ hints = _hints(target)
45
+ kwargs: dict[str, Any] = {}
46
+ for f in dataclasses.fields(target):
47
+ if not f.init:
48
+ continue
49
+ if f.name == "raw":
50
+ kwargs["raw"] = data
51
+ continue
52
+ if f.name in data:
53
+ kwargs[f.name] = _convert(hints[f.name], data[f.name])
54
+ return cls(**kwargs)
55
+
56
+
57
+ def _ts(value: Optional[int]) -> Optional[datetime]:
58
+ return datetime.fromtimestamp(value, tz=timezone.utc) if value else None
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class PlayerLevel:
63
+ level: int = 0
64
+ current_xp: int = 0
65
+ next_level_xp: int = 0
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class ProfileBasicInfo:
70
+ user_id: int = 0
71
+ name: str = ""
72
+ user_type: int = 1
73
+ icon_id: int = 0
74
+ player_level: PlayerLevel = field(default_factory=PlayerLevel)
75
+ last_seen_time: str = ""
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class Ban:
80
+ type: int = 0
81
+ reason: int = 0
82
+ days_left: int = 0
83
+
84
+ @property
85
+ def permanent(self) -> bool:
86
+ return self.type == 0
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class ClanBasicInfo:
91
+ id: int = 0
92
+ name: str = ""
93
+ tag: str = ""
94
+ emblem_id: int = 0
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class ClanMembership:
99
+ basic_info: ClanBasicInfo = field(default_factory=ClanBasicInfo)
100
+ member_rank: int = 10
101
+ joined: str = ""
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class MatchStats:
106
+ k: int = 0
107
+ d: int = 0
108
+ a: int = 0
109
+ w: int = 0
110
+ l: int = 0
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class SeasonStats:
115
+ season: int = 0
116
+ ranked: MatchStats = field(default_factory=MatchStats)
117
+ casual: MatchStats = field(default_factory=MatchStats)
118
+ custom: MatchStats = field(default_factory=MatchStats)
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class RankedStats:
123
+ placement_matches_left: int = 0
124
+ wins: int = 0
125
+ losses: int = 0
126
+ highest_rank: int = 0
127
+ mmr: int = 0
128
+ global_position: int = 0
129
+ rank: int = 0
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class LeaderboardPosition:
134
+ position: int = 0
135
+ score: int = 0
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class ProfileStats:
140
+ seasonal_stats: list[SeasonStats] = field(default_factory=list)
141
+ ranked: RankedStats = field(default_factory=RankedStats)
142
+ leaderboard_data: LeaderboardPosition = field(default_factory=LeaderboardPosition)
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class Profile:
147
+ basic_info: ProfileBasicInfo = field(default_factory=ProfileBasicInfo)
148
+ account_age: str = ""
149
+ blocked_friend_requests: bool = False
150
+ blocked_clan_requests: bool = False
151
+ party_invite_permissions: int = 0
152
+ ban: Optional[Ban] = None
153
+ stats: ProfileStats = field(default_factory=ProfileStats)
154
+ clan: Optional[ClanMembership] = None
155
+ raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
156
+
157
+ @property
158
+ def user_id(self) -> int:
159
+ return self.basic_info.user_id
160
+
161
+ @property
162
+ def name(self) -> str:
163
+ return self.basic_info.name
164
+
165
+
166
+ @dataclass(frozen=True)
167
+ class GameServer:
168
+ id: int
169
+ name: str
170
+ online: bool
171
+ region: Optional[str]
172
+
173
+
174
+ @dataclass(frozen=True)
175
+ class MatchHistoryEntry:
176
+ id: str = ""
177
+ game_type: int = 0
178
+ game_mode_id: int = 0
179
+ map_id: int = 0
180
+ team: int = 0
181
+ result: int = 0
182
+ left_match: bool = False
183
+ scores: list[int] = field(default_factory=list)
184
+ raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
185
+
186
+
187
+ @dataclass(frozen=True)
188
+ class PartyInfo:
189
+ is_leader: bool = False
190
+ players: list[int] = field(default_factory=list)
191
+
192
+
193
+ @dataclass(frozen=True)
194
+ class MatchPlayer:
195
+ user_id: int = 0
196
+ team: int = -1
197
+ kills: int = 0
198
+ deaths: int = 0
199
+ assists: int = 0
200
+ damage_dealt: int = 0
201
+ input_device_flags: int = 0
202
+ left_match: bool = False
203
+ leave_reason: Optional[str] = None
204
+ level: int = 0
205
+ match_presence: float = 0.0
206
+ party_info: Optional[PartyInfo] = None
207
+
208
+
209
+ @dataclass(frozen=True)
210
+ class MatchInfo:
211
+ id: str = ""
212
+ game_type: int = 0
213
+ game_mode_id: int = 0
214
+ server_id: int = 0
215
+ map_id: int = 0
216
+ team_factions: list[int] = field(default_factory=list)
217
+ winning_team: int = -1
218
+ win_condition: str = ""
219
+ scores: list[int] = field(default_factory=list)
220
+ started_at: int = 0
221
+ ended_at: int = 0
222
+ players: list[MatchPlayer] = field(default_factory=list)
223
+ replay_id: Optional[str] = None
224
+ raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
225
+
226
+ @property
227
+ def started(self) -> Optional[datetime]:
228
+ return _ts(self.started_at)
229
+
230
+ @property
231
+ def ended(self) -> Optional[datetime]:
232
+ return _ts(self.ended_at)
233
+
234
+
235
+ @dataclass(frozen=True)
236
+ class WeaponStats:
237
+ weapon_id: int = 0
238
+ name: str = ""
239
+ k: int = 0
240
+ a: int = 0
241
+ h: int = 0
242
+
243
+ @property
244
+ def headshot_rate(self) -> float:
245
+ return round(self.h / self.k, 3) if self.k else 0.0
246
+
247
+
248
+ @dataclass(frozen=True)
249
+ class LeaderboardEntry:
250
+ position: int = 0
251
+ score: int = 0
252
+ basic_info: ProfileBasicInfo = field(default_factory=ProfileBasicInfo)
253
+ ranked: RankedStats = field(default_factory=RankedStats)
254
+ clan: Optional[ClanMembership] = None
255
+
256
+
257
+ @dataclass(frozen=True)
258
+ class LeaderboardPage:
259
+ type: str
260
+ page: int
261
+ rank: Optional[int]
262
+ size: int
263
+ pages: int
264
+ entries: list[LeaderboardEntry]
265
+
266
+
267
+ @dataclass(frozen=True)
268
+ class PlayerMatchStats:
269
+ user_id: int = 0
270
+ team: int = -1
271
+ kills: int = 0
272
+ deaths: int = 0
273
+ assists: int = 0
274
+ damage_dealt: int = 0
275
+ input_device_flags: int = 0
276
+ left_match: bool = False
277
+ leave_reason: Optional[str] = None
278
+ ping: int = 0
279
+ user_type: int = 1
280
+ is_owner: bool = False
281
+
282
+ @property
283
+ def connected(self) -> bool:
284
+ return self.ping >= 0
285
+
286
+
287
+ @dataclass(frozen=True)
288
+ class AutohostRoom:
289
+ id: int = -1
290
+ code: Optional[str] = None
291
+ name: str = ""
292
+ map_id: int = 0
293
+ game_mode_id: int = 0
294
+ has_password: bool = False
295
+
296
+
297
+ @dataclass(frozen=True)
298
+ class AutohostResult:
299
+ match_id: Optional[str] = None
300
+ map_id: int = 0
301
+ team_factions: list[int] = field(default_factory=list)
302
+ winning_team: int = -1
303
+ win_condition: str = ""
304
+ scores: list[int] = field(default_factory=list)
305
+ started_at: int = 0
306
+ ended_at: int = 0
307
+ map_variation: int = 0
308
+ rounds_played: int = 0
309
+ players: list[PlayerMatchStats] = field(default_factory=list)
310
+
311
+
312
+ @dataclass(frozen=True)
313
+ class AutohostGame:
314
+ id: int = 0
315
+ status: Literal["open", "ended"] = "open"
316
+ created_at: int = 0
317
+ updated_at: int = 0
318
+ ended_at: Optional[int] = None
319
+ pending_expires_at: Optional[int] = None
320
+ server_id: int = 0
321
+ room: AutohostRoom = field(default_factory=AutohostRoom)
322
+ player_count: int = 0
323
+ match_started_at: Optional[int] = None
324
+ match_state: Optional[str] = None
325
+ round: int = 0
326
+ scores: list[int] = field(default_factory=list)
327
+ players: list[PlayerMatchStats] = field(default_factory=list)
328
+ results: list[AutohostResult] = field(default_factory=list)
329
+ replay_id: Optional[str] = None
330
+ rejection_reason: Optional[str] = None
331
+ raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
332
+
333
+ @property
334
+ def open(self) -> bool:
335
+ return self.status == "open"
336
+
337
+ @property
338
+ def code(self) -> Optional[str]:
339
+ return self.room.code
340
+
341
+
342
+ @dataclass(frozen=True)
343
+ class HostedRoom:
344
+ id: int
345
+ code: str
346
+ password: Optional[str]
347
+ server_id: int
348
+ game: AutohostGame
349
+
350
+
351
+ @dataclass(frozen=True)
352
+ class QuotaSnapshot:
353
+ limit: int
354
+ remaining: int
355
+ reset_at: float
356
+ resets_in: float
357
+ reserve: int
358
+
359
+
360
+ @dataclass(frozen=True)
361
+ class TokenCheck:
362
+ valid: bool
363
+ autohost: bool
364
+ error: Optional[Exception]
365
+ quota: QuotaSnapshot
copsapi/py.typed ADDED
File without changes
copsapi/quota.py ADDED
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import threading
5
+ import time
6
+ from typing import Callable, Mapping, Optional
7
+
8
+ from .errors import make_error
9
+ from .models import QuotaSnapshot
10
+
11
+
12
+ def _next_minute(now: float) -> float:
13
+ return (math.floor(now / 60) + 1) * 60
14
+
15
+
16
+ class QuotaTracker:
17
+ def __init__(
18
+ self,
19
+ limit: int = 60,
20
+ reserve: int = 0,
21
+ on_exhausted: str = "wait",
22
+ max_wait: float = 65.0,
23
+ clock: Callable[[], float] = time.time,
24
+ ) -> None:
25
+ if on_exhausted not in ("wait", "raise"):
26
+ raise ValueError("on_exhausted must be 'wait' or 'raise'")
27
+ self._limit = limit
28
+ self._remaining = limit
29
+ self._reset_at = 0.0
30
+ self._in_flight = 0
31
+ self._reserve = max(0, reserve)
32
+ self._mode = on_exhausted
33
+ self._max_wait = max_wait
34
+ self._clock = clock
35
+ self._lock = threading.Lock()
36
+
37
+ def _roll(self, now: float) -> None:
38
+ if now >= self._reset_at:
39
+ self._remaining = self._limit
40
+ self._reset_at = _next_minute(now)
41
+
42
+ def try_acquire(self, priority: str) -> float:
43
+ with self._lock:
44
+ now = self._clock()
45
+ self._roll(now)
46
+ floor = self._reserve if priority == "normal" else 0
47
+ if self._remaining > floor:
48
+ self._remaining -= 1
49
+ self._in_flight += 1
50
+ return 0.0
51
+ wait = self._reset_at - now + 0.025
52
+ if self._mode == "raise" or wait > self._max_wait:
53
+ why = (
54
+ f"the last {floor} requests of this minute are reserved for high-priority calls"
55
+ if floor > 0
56
+ else "the per-minute quota is used up"
57
+ )
58
+ raise make_error("rate_limited", f"Request held back: {why}.", 429, math.ceil(wait), local=True)
59
+ return wait
60
+
61
+ def release(self) -> None:
62
+ with self._lock:
63
+ self._in_flight = max(0, self._in_flight - 1)
64
+
65
+ def update(self, headers: Mapping[str, str]) -> None:
66
+ def number(name: str) -> Optional[float]:
67
+ value = headers.get(name)
68
+ try:
69
+ return float(value) if value is not None else None
70
+ except ValueError:
71
+ return None
72
+
73
+ limit = number("x-ratelimit-limit")
74
+ remaining = number("x-ratelimit-remaining")
75
+ reset = number("x-ratelimit-reset")
76
+ with self._lock:
77
+ if limit and limit > 0:
78
+ self._limit = int(limit)
79
+ if reset and reset > 0:
80
+ self._reset_at = reset
81
+ if remaining is not None:
82
+ self._remaining = max(0, int(remaining) - max(0, self._in_flight - 1))
83
+
84
+ def exhaust(self, retry_after: Optional[float]) -> None:
85
+ with self._lock:
86
+ self._remaining = 0
87
+ if retry_after is not None:
88
+ self._reset_at = self._clock() + retry_after
89
+
90
+ def seconds_until_reset(self) -> float:
91
+ return max(0.0, self._reset_at - self._clock())
92
+
93
+ def snapshot(self) -> QuotaSnapshot:
94
+ with self._lock:
95
+ now = self._clock()
96
+ self._roll(now)
97
+ return QuotaSnapshot(self._limit, self._remaining, self._reset_at, max(0.0, self._reset_at - now), self._reserve)