python-ballchasing 0.5.1__py3-none-any.whl → 0.5.3__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.
- ballchasing/__init__.py +1 -0
- ballchasing/api.py +224 -81
- ballchasing/rate_limit.py +227 -0
- ballchasing/typed/shared.py +6 -5
- ballchasing/util/replays.py +2 -2
- {python_ballchasing-0.5.1.dist-info → python_ballchasing-0.5.3.dist-info}/METADATA +1 -1
- {python_ballchasing-0.5.1.dist-info → python_ballchasing-0.5.3.dist-info}/RECORD +11 -9
- {python_ballchasing-0.5.1.dist-info → python_ballchasing-0.5.3.dist-info}/top_level.txt +1 -0
- tests/test_fixes.py +430 -0
- {python_ballchasing-0.5.1.dist-info → python_ballchasing-0.5.3.dist-info}/WHEEL +0 -0
- {python_ballchasing-0.5.1.dist-info → python_ballchasing-0.5.3.dist-info}/licenses/LICENSE +0 -0
ballchasing/__init__.py
CHANGED
|
@@ -49,6 +49,7 @@ except Exception:
|
|
|
49
49
|
|
|
50
50
|
# Import the main classes and constants to make them easily accessible to users.
|
|
51
51
|
from .api import BallchasingApi
|
|
52
|
+
from .rate_limit import RateLimiter, RateLimitCategory, rate_limited
|
|
52
53
|
|
|
53
54
|
Api = BallchasingApi # For importing like `import ballchasing; api = ballchasing.Api`
|
|
54
55
|
|
ballchasing/api.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
import os
|
|
2
3
|
import time
|
|
3
4
|
from concurrent.futures import ThreadPoolExecutor
|
|
@@ -6,18 +7,30 @@ from pathlib import Path
|
|
|
6
7
|
from typing import BinaryIO, Iterator
|
|
7
8
|
from urllib.parse import parse_qs, urlparse
|
|
8
9
|
|
|
9
|
-
from requests import sessions, Response
|
|
10
|
+
from requests import sessions, Response
|
|
11
|
+
from requests.exceptions import ConnectionError, Timeout
|
|
10
12
|
|
|
11
13
|
from ballchasing.constants import GroupSortBy, SortDir, AnySeason, AnyRank, AnyReplaySortBy, AnySortDir, \
|
|
12
14
|
AnyVisibility, AnyGroupSortBy, AnyPlayerIdentification, AnyTeamIdentification, AnyMatchResult, NoneOrMore
|
|
15
|
+
from ballchasing.rate_limit import RateLimiter, RateLimitCategory, resolve_category, rate_limited
|
|
13
16
|
from ballchasing.typed import DeepReplay, ShallowReplay, DeepGroup, ShallowGroup
|
|
14
17
|
from .typed.shared import BaseGroup, BasicGroup
|
|
15
18
|
from .util.dates import to_rfc3339
|
|
16
19
|
from .util.iterators import deduplicate as deduplicator
|
|
17
20
|
from .util.stats import parse_replay_stats
|
|
18
21
|
|
|
22
|
+
logger = logging.getLogger("ballchasing")
|
|
23
|
+
|
|
19
24
|
DEFAULT_URL = "https://ballchasing.com/api"
|
|
20
25
|
|
|
26
|
+
PATRON_RATE_LIMIT_SLEEP_TIMES = {
|
|
27
|
+
"regular": 3600 / 1000,
|
|
28
|
+
"gold": 3600 / 2000,
|
|
29
|
+
"diamond": 3600 / 5000,
|
|
30
|
+
"champion": 1 / 8,
|
|
31
|
+
"gc": 1 / 16,
|
|
32
|
+
}
|
|
33
|
+
|
|
21
34
|
|
|
22
35
|
class BallchasingApi:
|
|
23
36
|
"""
|
|
@@ -33,6 +46,7 @@ class BallchasingApi:
|
|
|
33
46
|
base_url: str | None = None,
|
|
34
47
|
do_initial_ping: bool = True,
|
|
35
48
|
typed: bool = False,
|
|
49
|
+
proactive_rate_limit: bool = False,
|
|
36
50
|
):
|
|
37
51
|
"""
|
|
38
52
|
|
|
@@ -40,6 +54,7 @@ class BallchasingApi:
|
|
|
40
54
|
:param sleep_time_on_rate_limit: seconds to wait after being rate limited.
|
|
41
55
|
Default value is calculated depending on patron type.
|
|
42
56
|
:param print_on_rate_limit: whether or not to print upon rate limits.
|
|
57
|
+
:param proactive_rate_limit: whether to pace requests proactively before hitting limits.
|
|
43
58
|
"""
|
|
44
59
|
self.auth_key = auth_key
|
|
45
60
|
self._session = sessions.Session()
|
|
@@ -48,18 +63,40 @@ class BallchasingApi:
|
|
|
48
63
|
self.base_url = DEFAULT_URL if base_url is None else base_url
|
|
49
64
|
if do_initial_ping:
|
|
50
65
|
self.ping()
|
|
51
|
-
|
|
52
|
-
self.sleep_time_on_rate_limit = {
|
|
53
|
-
"regular": 3600 / 1000,
|
|
54
|
-
"gold": 3600 / 2000,
|
|
55
|
-
"diamond": 3600 / 5000,
|
|
56
|
-
"champion": 1 / 8,
|
|
57
|
-
"gc": 1 / 16
|
|
58
|
-
}.get(self.patron_type or "regular")
|
|
59
|
-
else:
|
|
60
|
-
self.sleep_time_on_rate_limit = sleep_time_on_rate_limit
|
|
66
|
+
self._sleep_time_on_rate_limit = sleep_time_on_rate_limit
|
|
61
67
|
self.print_on_rate_limit = print_on_rate_limit
|
|
62
68
|
self.typed = typed
|
|
69
|
+
self.rate_limiter = RateLimiter(
|
|
70
|
+
patron_type_getter=lambda: self._ping_result.get("type") if self._ping_result else None,
|
|
71
|
+
proactive=proactive_rate_limit,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def sleep_time_on_rate_limit(self) -> float:
|
|
76
|
+
if self._sleep_time_on_rate_limit is not None:
|
|
77
|
+
return self._sleep_time_on_rate_limit
|
|
78
|
+
if self._ping_result is not None:
|
|
79
|
+
return PATRON_RATE_LIMIT_SLEEP_TIMES.get(self.patron_type or "regular", 3600 / 1000)
|
|
80
|
+
return PATRON_RATE_LIMIT_SLEEP_TIMES["regular"]
|
|
81
|
+
|
|
82
|
+
@sleep_time_on_rate_limit.setter
|
|
83
|
+
def sleep_time_on_rate_limit(self, value: float | None):
|
|
84
|
+
self._sleep_time_on_rate_limit = value
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def last_rate_limited_at(self) -> float | None:
|
|
88
|
+
"""Timestamp (time.time()) of the most recent 429 response, or None if never rate-limited."""
|
|
89
|
+
return self.rate_limiter.last_rate_limited_at
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def is_rate_limited(self) -> bool:
|
|
93
|
+
"""True if the instance is currently waiting out a 429 rate-limit cooldown."""
|
|
94
|
+
return self.rate_limiter.is_rate_limited
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def rate_limit_stats(self) -> dict:
|
|
98
|
+
"""Detailed rate-limiting statistics across all categories."""
|
|
99
|
+
return self.rate_limiter.get_stats()
|
|
63
100
|
|
|
64
101
|
@property
|
|
65
102
|
def steam_name(self):
|
|
@@ -89,49 +126,71 @@ class BallchasingApi:
|
|
|
89
126
|
self,
|
|
90
127
|
url_or_endpoint: str,
|
|
91
128
|
method: str,
|
|
129
|
+
allow_status_codes: tuple[int, ...] = (),
|
|
130
|
+
category: RateLimitCategory | str | None = None,
|
|
92
131
|
**params
|
|
93
132
|
) -> Response:
|
|
94
133
|
"""
|
|
95
134
|
Helper method for all requests.
|
|
96
135
|
|
|
97
|
-
:param
|
|
136
|
+
:param url_or_endpoint: url or endpoint for request.
|
|
98
137
|
:param method: the method to use.
|
|
99
|
-
:param
|
|
138
|
+
:param allow_status_codes: additional HTTP status codes to treat as successful responses.
|
|
139
|
+
:param category: rate limit category for this request.
|
|
140
|
+
:param params: parameters for request (e.g. params, json, files, stream).
|
|
100
141
|
:return: the request result.
|
|
101
142
|
:raises ConnectionError: if the connection fails after max retries.
|
|
102
|
-
:raises HTTPError: if the request fails with a status code other than 2xx or
|
|
143
|
+
:raises HTTPError: if the request fails with a status code other than 2xx, 429, or allow_status_codes.
|
|
103
144
|
"""
|
|
145
|
+
cat = RateLimitCategory(category) if category else resolve_category(url_or_endpoint)
|
|
104
146
|
headers = {"Authorization": self.auth_key}
|
|
105
147
|
url = f"{self.base_url}{url_or_endpoint}" if url_or_endpoint.startswith("/") else url_or_endpoint
|
|
106
148
|
max_retries = 8
|
|
107
149
|
retries = 0
|
|
150
|
+
rate_limit_retries = 0
|
|
151
|
+
|
|
152
|
+
# Wait if global cooldown is active or if proactive pacing applies
|
|
153
|
+
self.rate_limiter.before_request(cat)
|
|
154
|
+
|
|
108
155
|
while True:
|
|
109
156
|
try:
|
|
110
157
|
r: Response = self._session.request(method=method, url=url, headers=headers, **params)
|
|
111
|
-
if 200 <= r.status_code < 300:
|
|
158
|
+
if 200 <= r.status_code < 300 or r.status_code in allow_status_codes:
|
|
112
159
|
return r
|
|
113
160
|
elif r.status_code == 429:
|
|
114
161
|
self.rate_limit_count += 1
|
|
115
|
-
|
|
116
|
-
print(f"Rate limited at {url} ({self.rate_limit_count} total rate limits)")
|
|
162
|
+
rate_limit_retries += 1
|
|
117
163
|
retry_after = r.headers.get("Retry-After", '0')
|
|
118
|
-
retry_after =
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
164
|
+
retry_after = float(retry_after) if retry_after.isdigit() else None
|
|
165
|
+
wait_time = self.rate_limiter.record_429(
|
|
166
|
+
cat,
|
|
167
|
+
retry_after=retry_after,
|
|
168
|
+
fallback_sleep_time=self._sleep_time_on_rate_limit,
|
|
169
|
+
retries=rate_limit_retries,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if self.print_on_rate_limit:
|
|
173
|
+
logger.warning(
|
|
174
|
+
f"Rate limited at {url} [{cat.value}] ({self.rate_limit_count} total rate limits), retrying in {wait_time:.1f}s..."
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
logger.debug(
|
|
178
|
+
f"Rate limited at {url} [{cat.value}] ({self.rate_limit_count} total rate limits), retrying in {wait_time:.1f}s..."
|
|
179
|
+
)
|
|
180
|
+
time.sleep(wait_time)
|
|
181
|
+
elif r.status_code in (502, 503, 504):
|
|
182
|
+
raise ConnectionError(f"Server error {r.status_code}: {r.reason or 'Transient gateway error'}")
|
|
125
183
|
else:
|
|
126
|
-
r.raise_for_status() # Raise an error for any other status code
|
|
127
|
-
except ConnectionError as e:
|
|
184
|
+
r.raise_for_status() # Raise an error for any other status code
|
|
185
|
+
except (ConnectionError, Timeout) as e:
|
|
128
186
|
if retries >= max_retries - 1:
|
|
129
187
|
raise e
|
|
130
188
|
s = 2 ** retries
|
|
131
|
-
|
|
189
|
+
logger.warning(f"Connection or timeout error ({e}), trying again in {s} seconds...")
|
|
132
190
|
time.sleep(s)
|
|
133
191
|
retries += 1
|
|
134
192
|
|
|
193
|
+
@rate_limited(RateLimitCategory.PING)
|
|
135
194
|
def ping(self) -> dict:
|
|
136
195
|
"""
|
|
137
196
|
Use this API to:
|
|
@@ -142,7 +201,7 @@ class BallchasingApi:
|
|
|
142
201
|
This method runs automatically at initialization and the steam name and id as well as patron type are stored.
|
|
143
202
|
:return: ping response.
|
|
144
203
|
"""
|
|
145
|
-
result = self._request("/", "GET").json()
|
|
204
|
+
result = self._request("/", "GET", category=RateLimitCategory.PING).json()
|
|
146
205
|
self._ping_result = result
|
|
147
206
|
return result
|
|
148
207
|
|
|
@@ -153,10 +212,12 @@ class BallchasingApi:
|
|
|
153
212
|
# consumer processing items. When prefetch=False (e.g. deep mode
|
|
154
213
|
# where the consumer also makes API calls), the next request is
|
|
155
214
|
# submitted *after* yielding to avoid doubling the request rate.
|
|
156
|
-
remaining = params
|
|
215
|
+
remaining = params.get("count", 0)
|
|
216
|
+
if remaining <= 0:
|
|
217
|
+
return
|
|
157
218
|
|
|
158
219
|
def fetch_page(p):
|
|
159
|
-
return self._request(url, "GET", params=p).json()
|
|
220
|
+
return self._request(url, "GET", params=p, category=RateLimitCategory.LIST).json()
|
|
160
221
|
|
|
161
222
|
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
162
223
|
params["count"] = min(remaining, 200)
|
|
@@ -164,15 +225,19 @@ class BallchasingApi:
|
|
|
164
225
|
|
|
165
226
|
while remaining > 0:
|
|
166
227
|
d = future.result()
|
|
167
|
-
batch = d
|
|
228
|
+
batch = d.get("list", [])[:min(remaining, 200)]
|
|
168
229
|
remaining -= len(batch)
|
|
169
230
|
|
|
170
|
-
|
|
231
|
+
next_url = d.get("next")
|
|
232
|
+
has_next = bool(next_url) and remaining > 0
|
|
171
233
|
if has_next:
|
|
172
|
-
|
|
173
|
-
|
|
234
|
+
after_param = parse_qs(urlparse(next_url).query).get("after", [None])[0]
|
|
235
|
+
if after_param is not None:
|
|
236
|
+
params["after"] = after_param
|
|
237
|
+
else:
|
|
238
|
+
has_next = False
|
|
174
239
|
params["count"] = min(remaining, 200)
|
|
175
|
-
if prefetch:
|
|
240
|
+
if prefetch and has_next:
|
|
176
241
|
future = executor.submit(fetch_page, dict(params))
|
|
177
242
|
|
|
178
243
|
yield from batch
|
|
@@ -182,6 +247,7 @@ class BallchasingApi:
|
|
|
182
247
|
if not prefetch:
|
|
183
248
|
future = executor.submit(fetch_page, dict(params))
|
|
184
249
|
|
|
250
|
+
@rate_limited(RateLimitCategory.LIST)
|
|
185
251
|
def get_replays(
|
|
186
252
|
self,
|
|
187
253
|
*,
|
|
@@ -238,8 +304,8 @@ class BallchasingApi:
|
|
|
238
304
|
RFC3339 format, e.g. '2020-01-02T15:00:05+01:00'
|
|
239
305
|
:param count: returns at most count replays. Since the implementation uses an iterator it supports iterating
|
|
240
306
|
past the limit of 200 set by the API
|
|
241
|
-
:param sort_by: sort replays according the selected field
|
|
242
|
-
:param sort_dir: sort direction
|
|
307
|
+
:param sort_by: sort replays according the selected field ('replay-date', 'upload-date')
|
|
308
|
+
:param sort_dir: sort direction ('asc', 'desc')
|
|
243
309
|
:param deep: whether to get full stats for each replay (will be much slower).
|
|
244
310
|
:param typed: whether to return a typed object (default is self.typed).
|
|
245
311
|
:param deduplicate: whether to deduplicate replays that seem to be the same game.
|
|
@@ -262,11 +328,11 @@ class BallchasingApi:
|
|
|
262
328
|
disable_prefetch = deep
|
|
263
329
|
|
|
264
330
|
iterator = self._iterable_from_request(url, params, prefetch=not disable_prefetch)
|
|
265
|
-
if deep:
|
|
266
|
-
iterator = (self.get_replay(r["id"]) for r in iterator)
|
|
267
331
|
if deduplicate:
|
|
268
332
|
# Deep replays have match and replay IDs to deduplicate with. For shallow replays we check dates.
|
|
269
333
|
iterator = deduplicator(iterator, check_dates=not deep)
|
|
334
|
+
if deep:
|
|
335
|
+
iterator = (self.get_replay(r["id"]) for r in iterator)
|
|
270
336
|
if typed:
|
|
271
337
|
if deep:
|
|
272
338
|
iterator = (DeepReplay(**r) for r in iterator)
|
|
@@ -274,6 +340,7 @@ class BallchasingApi:
|
|
|
274
340
|
iterator = (ShallowReplay(**r) for r in iterator)
|
|
275
341
|
yield from iterator
|
|
276
342
|
|
|
343
|
+
@rate_limited(RateLimitCategory.CORE)
|
|
277
344
|
def get_replay(self, replay_id: str, *, typed: bool | None = None) -> dict | DeepReplay:
|
|
278
345
|
"""
|
|
279
346
|
Retrieve a given replay’s details and stats.
|
|
@@ -282,43 +349,82 @@ class BallchasingApi:
|
|
|
282
349
|
:param typed: whether to return a typed object (default is self.typed).
|
|
283
350
|
:return: the result of the GET request.
|
|
284
351
|
"""
|
|
285
|
-
result = self._request(f"/replays/{replay_id}", "GET").json()
|
|
352
|
+
result = self._request(f"/replays/{replay_id}", "GET", category=RateLimitCategory.CORE).json()
|
|
286
353
|
if typed is None:
|
|
287
354
|
typed = self.typed
|
|
288
355
|
if typed:
|
|
289
356
|
result = DeepReplay(**result)
|
|
290
357
|
return result
|
|
291
358
|
|
|
292
|
-
|
|
359
|
+
@rate_limited(RateLimitCategory.CORE)
|
|
360
|
+
def patch_replay(
|
|
361
|
+
self,
|
|
362
|
+
replay_id: str,
|
|
363
|
+
*,
|
|
364
|
+
title: str | None = None,
|
|
365
|
+
visibility: AnyVisibility | None = None,
|
|
366
|
+
group: str | None = None,
|
|
367
|
+
**params
|
|
368
|
+
) -> None:
|
|
293
369
|
"""
|
|
294
|
-
This endpoint can patch one or more fields of the specified replay
|
|
370
|
+
This endpoint can patch one or more fields of the specified replay.
|
|
295
371
|
|
|
296
372
|
:param replay_id: the replay id.
|
|
297
|
-
:param
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
373
|
+
:param title: new title for the replay.
|
|
374
|
+
:param visibility: new visibility ('public', 'unlisted', 'private').
|
|
375
|
+
:param group: group id to assign, or "" to unassign replay from group.
|
|
376
|
+
:param params: additional parameters for the PATCH request.
|
|
377
|
+
"""
|
|
378
|
+
payload = dict(params)
|
|
379
|
+
if title is not None:
|
|
380
|
+
payload["title"] = title
|
|
381
|
+
if visibility is not None:
|
|
382
|
+
payload["visibility"] = visibility
|
|
383
|
+
if group is not None:
|
|
384
|
+
payload["group"] = group
|
|
385
|
+
self._request(f"/replays/{replay_id}", "PATCH", json=payload, category=RateLimitCategory.CORE)
|
|
386
|
+
|
|
387
|
+
@rate_limited(RateLimitCategory.CORE)
|
|
301
388
|
def upload_replay(
|
|
302
389
|
self,
|
|
303
390
|
replay_file: str | Path | BinaryIO,
|
|
304
391
|
*,
|
|
305
392
|
visibility: AnyVisibility | None = None,
|
|
306
|
-
group: str | None = None
|
|
393
|
+
group: str | None = None,
|
|
394
|
+
raise_on_duplicate: bool = False,
|
|
307
395
|
) -> dict:
|
|
308
396
|
"""
|
|
309
397
|
Use this API to upload a replay file to ballchasing.com.
|
|
310
398
|
|
|
311
399
|
:param replay_file: replay file to upload. Can be a file path (str or Path) or a file-like object.
|
|
312
|
-
:param visibility: to set the visibility of the uploaded replay.
|
|
400
|
+
:param visibility: to set the visibility of the uploaded replay ('public', 'unlisted', 'private').
|
|
313
401
|
:param group: to upload the replay to an existing group.
|
|
314
|
-
:
|
|
402
|
+
:param raise_on_duplicate: if True, raise an HTTPError when a 409 Conflict (duplicate replay)
|
|
403
|
+
is returned. If False (default), returns the API response dict with the
|
|
404
|
+
existing replay id.
|
|
405
|
+
:return: the result of the POST request (dict containing replay 'id').
|
|
315
406
|
"""
|
|
316
407
|
if isinstance(replay_file, (str, Path)):
|
|
317
408
|
with open(replay_file, "rb") as f:
|
|
318
|
-
return self.upload_replay(
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
409
|
+
return self.upload_replay(
|
|
410
|
+
f,
|
|
411
|
+
visibility=visibility,
|
|
412
|
+
group=group,
|
|
413
|
+
raise_on_duplicate=raise_on_duplicate,
|
|
414
|
+
)
|
|
415
|
+
r = self._request(
|
|
416
|
+
"/v2/upload",
|
|
417
|
+
"POST",
|
|
418
|
+
files={"file": replay_file},
|
|
419
|
+
params={"group": group, "visibility": visibility},
|
|
420
|
+
allow_status_codes=(409,),
|
|
421
|
+
category=RateLimitCategory.CORE,
|
|
422
|
+
)
|
|
423
|
+
if r.status_code == 409 and raise_on_duplicate:
|
|
424
|
+
r.raise_for_status()
|
|
425
|
+
return r.json()
|
|
426
|
+
|
|
427
|
+
@rate_limited(RateLimitCategory.CORE)
|
|
322
428
|
def delete_replay(self, replay_id: str) -> None:
|
|
323
429
|
"""
|
|
324
430
|
This endpoint deletes the specified replay.
|
|
@@ -326,8 +432,9 @@ class BallchasingApi:
|
|
|
326
432
|
|
|
327
433
|
:param replay_id: the replay id.
|
|
328
434
|
"""
|
|
329
|
-
self._request(f"/replays/{replay_id}", "DELETE")
|
|
435
|
+
self._request(f"/replays/{replay_id}", "DELETE", category=RateLimitCategory.CORE)
|
|
330
436
|
|
|
437
|
+
@rate_limited(RateLimitCategory.LIST)
|
|
331
438
|
def get_groups(
|
|
332
439
|
self,
|
|
333
440
|
*,
|
|
@@ -356,14 +463,14 @@ class BallchasingApi:
|
|
|
356
463
|
RFC3339 format, e.g. 2020-01-02T15:00:05+01:00
|
|
357
464
|
:param count: returns at most count groups. Since the implementation uses an iterator it supports iterating
|
|
358
465
|
past the limit of 200 set by the API
|
|
359
|
-
:param sort_by: Sort groups according the selected field.
|
|
360
|
-
:param sort_dir: Sort direction.
|
|
466
|
+
:param sort_by: Sort groups according the selected field ('created', 'name').
|
|
467
|
+
:param sort_dir: Sort direction ('asc', 'desc').
|
|
361
468
|
:param deep: whether to get full stats for each group (will be much slower).
|
|
362
469
|
:param typed: whether to return a typed object (default is self.typed).
|
|
363
470
|
:param disable_prefetch: whether to disable prefetching.
|
|
364
471
|
:return: an iterator over the groups returned by the API.
|
|
365
472
|
"""
|
|
366
|
-
url = f"{self.base_url}/groups
|
|
473
|
+
url = f"{self.base_url}/groups"
|
|
367
474
|
params = {"name": name, "creator": creator, "group": group, "created-before": to_rfc3339(created_before),
|
|
368
475
|
"created-after": to_rfc3339(created_after), "count": count, "sort-by": sort_by, "sort-dir": sort_dir}
|
|
369
476
|
|
|
@@ -379,6 +486,7 @@ class BallchasingApi:
|
|
|
379
486
|
iterator = (ShallowGroup(**g) for g in iterator)
|
|
380
487
|
yield from iterator
|
|
381
488
|
|
|
489
|
+
@rate_limited(RateLimitCategory.CORE)
|
|
382
490
|
def create_group(
|
|
383
491
|
self,
|
|
384
492
|
*,
|
|
@@ -391,7 +499,7 @@ class BallchasingApi:
|
|
|
391
499
|
Use this API to create a new replay group.
|
|
392
500
|
|
|
393
501
|
:param name: the new group name.
|
|
394
|
-
:param player_identification: how to identify the same player across multiple replays.
|
|
502
|
+
:param player_identification: how to identify the same player across multiple replays ('by-id', 'by-name').
|
|
395
503
|
Some tournaments (e.g. RLCS) make players use a pool of generic Steam accounts,
|
|
396
504
|
meaning the same player could end up using 2 different accounts in 2 series.
|
|
397
505
|
That's when the `by-name` comes in handy
|
|
@@ -399,13 +507,19 @@ class BallchasingApi:
|
|
|
399
507
|
Set to `by-distinct-players` if teams have a fixed roster of players for
|
|
400
508
|
every single game. In some tournaments/leagues, teams allow player rotations,
|
|
401
509
|
or a sub can replace another player, in which case use `by-player-clusters`.
|
|
402
|
-
:param parent: if set,the new group will be created as a child of the specified group
|
|
510
|
+
:param parent: if set, the new group will be created as a child of the specified group
|
|
403
511
|
:return: the result of the POST request.
|
|
404
512
|
"""
|
|
405
|
-
json = {
|
|
406
|
-
|
|
407
|
-
|
|
513
|
+
json = {
|
|
514
|
+
"name": name,
|
|
515
|
+
"player_identification": player_identification,
|
|
516
|
+
"team_identification": team_identification,
|
|
517
|
+
}
|
|
518
|
+
if parent is not None:
|
|
519
|
+
json["parent"] = parent
|
|
520
|
+
return self._request(f"/groups", "POST", json=json, category=RateLimitCategory.CORE).json()
|
|
408
521
|
|
|
522
|
+
@rate_limited(RateLimitCategory.CORE)
|
|
409
523
|
def get_group(
|
|
410
524
|
self,
|
|
411
525
|
group_id: str,
|
|
@@ -419,22 +533,46 @@ class BallchasingApi:
|
|
|
419
533
|
:param typed: whether to return a typed object (default is self.typed).
|
|
420
534
|
:return: the group info with stats.
|
|
421
535
|
"""
|
|
422
|
-
result = self._request(f"/groups/{group_id}", "GET").json()
|
|
536
|
+
result = self._request(f"/groups/{group_id}", "GET", category=RateLimitCategory.CORE).json()
|
|
423
537
|
if typed is None:
|
|
424
538
|
typed = self.typed
|
|
425
539
|
if typed:
|
|
426
540
|
result = DeepGroup(**result)
|
|
427
541
|
return result
|
|
428
542
|
|
|
429
|
-
|
|
543
|
+
@rate_limited(RateLimitCategory.CORE)
|
|
544
|
+
def patch_group(
|
|
545
|
+
self,
|
|
546
|
+
group_id: str,
|
|
547
|
+
*,
|
|
548
|
+
player_identification: AnyPlayerIdentification | None = None,
|
|
549
|
+
team_identification: AnyTeamIdentification | None = None,
|
|
550
|
+
parent: str | None = None,
|
|
551
|
+
shared: bool | None = None,
|
|
552
|
+
**params
|
|
553
|
+
) -> None:
|
|
430
554
|
"""
|
|
431
555
|
This endpoint can patch one or more fields of the specified group.
|
|
432
556
|
|
|
433
557
|
:param group_id: the group id
|
|
434
|
-
:param
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
558
|
+
:param player_identification: how to identify the same player across multiple replays ('by-id', 'by-name').
|
|
559
|
+
:param team_identification: how to identify the same team ('by-distinct-players', 'by-player-clusters').
|
|
560
|
+
:param parent: parent group id to assign, or "" to unparent.
|
|
561
|
+
:param shared: whether to mark the group as shared.
|
|
562
|
+
:param params: additional parameters for the PATCH request.
|
|
563
|
+
"""
|
|
564
|
+
payload = dict(params)
|
|
565
|
+
if player_identification is not None:
|
|
566
|
+
payload["player_identification"] = player_identification
|
|
567
|
+
if team_identification is not None:
|
|
568
|
+
payload["team_identification"] = team_identification
|
|
569
|
+
if parent is not None:
|
|
570
|
+
payload["parent"] = parent
|
|
571
|
+
if shared is not None:
|
|
572
|
+
payload["shared"] = shared
|
|
573
|
+
self._request(f"/groups/{group_id}", "PATCH", json=payload, category=RateLimitCategory.CORE)
|
|
574
|
+
|
|
575
|
+
@rate_limited(RateLimitCategory.CORE)
|
|
438
576
|
def delete_group(self, group_id: str) -> None:
|
|
439
577
|
"""
|
|
440
578
|
This endpoint deletes the specified group.
|
|
@@ -442,7 +580,7 @@ class BallchasingApi:
|
|
|
442
580
|
|
|
443
581
|
:param group_id: the group id.
|
|
444
582
|
"""
|
|
445
|
-
self._request(f"/groups/{group_id}", "DELETE")
|
|
583
|
+
self._request(f"/groups/{group_id}", "DELETE", category=RateLimitCategory.CORE)
|
|
446
584
|
|
|
447
585
|
def get_group_replays(
|
|
448
586
|
self,
|
|
@@ -490,22 +628,25 @@ class BallchasingApi:
|
|
|
490
628
|
for replay in self.get_replays(group_id=group_id, deep=deep, typed=typed):
|
|
491
629
|
yield [group_id], replay
|
|
492
630
|
|
|
493
|
-
|
|
631
|
+
@rate_limited(RateLimitCategory.DOWNLOAD)
|
|
632
|
+
def download_replay(self, replay_id: str, path: str | Path) -> Path:
|
|
494
633
|
"""
|
|
495
634
|
Download a replay file.
|
|
496
635
|
|
|
497
636
|
:param replay_id: the replay id.
|
|
498
637
|
:param path: the path to download the replay to. Can be a file path or a directory.
|
|
638
|
+
:return: the Path to the downloaded file.
|
|
499
639
|
"""
|
|
500
|
-
|
|
501
|
-
if
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
640
|
+
dest = Path(path)
|
|
641
|
+
if dest.is_dir():
|
|
642
|
+
dest = dest / f"{replay_id}.replay"
|
|
643
|
+
r = self._request(f"/replays/{replay_id}/file", "GET", stream=True, category=RateLimitCategory.DOWNLOAD)
|
|
644
|
+
with open(dest, "wb") as f:
|
|
645
|
+
for chunk in r.iter_content(chunk_size=8192):
|
|
646
|
+
f.write(chunk)
|
|
647
|
+
return dest
|
|
507
648
|
|
|
508
|
-
def download_group(self, group_id: str, folder: str, *, keep_tree_structure=True):
|
|
649
|
+
def download_group(self, group_id: str, folder: str | Path, *, keep_tree_structure=True):
|
|
509
650
|
"""
|
|
510
651
|
Download an entire group.
|
|
511
652
|
|
|
@@ -513,22 +654,24 @@ class BallchasingApi:
|
|
|
513
654
|
:param folder: the folder in which to create the group folder.
|
|
514
655
|
:param keep_tree_structure: whether to create new folders for child groups.
|
|
515
656
|
"""
|
|
516
|
-
folder =
|
|
657
|
+
folder = Path(folder) / group_id
|
|
517
658
|
if keep_tree_structure:
|
|
518
|
-
|
|
659
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
519
660
|
for child_group in self.get_groups(group=group_id):
|
|
520
661
|
self.download_group(child_group["id"], folder, keep_tree_structure=True)
|
|
521
662
|
for replay in self.get_replays(group_id=group_id):
|
|
522
663
|
self.download_replay(replay["id"], folder)
|
|
523
664
|
else:
|
|
665
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
524
666
|
for replay in self.get_group_replays(group_id):
|
|
525
667
|
self.download_replay(replay["id"], folder)
|
|
526
668
|
|
|
669
|
+
@rate_limited(RateLimitCategory.CORE)
|
|
527
670
|
def get_maps(self):
|
|
528
671
|
"""
|
|
529
672
|
Use this API to get the list of map codes to map names (map as in stadium).
|
|
530
673
|
"""
|
|
531
|
-
res = self._request("/maps", "GET").json()
|
|
674
|
+
res = self._request("/maps", "GET", category=RateLimitCategory.CORE).json()
|
|
532
675
|
return res
|
|
533
676
|
|
|
534
677
|
def get_stats(self, replay: dict | str):
|