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