python-ballchasing 0.5.2__tar.gz → 0.5.4__tar.gz
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.
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/PKG-INFO +1 -1
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/__init__.py +1 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/api.py +178 -64
- python_ballchasing-0.5.4/ballchasing/rate_limit.py +227 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/pyproject.toml +1 -1
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/python_ballchasing.egg-info/PKG-INFO +1 -1
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/python_ballchasing.egg-info/SOURCES.txt +1 -0
- python_ballchasing-0.5.4/tests/test_fixes.py +439 -0
- python_ballchasing-0.5.2/tests/test_fixes.py +0 -246
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/LICENSE +0 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/README.md +0 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/constants.py +0 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/stats_info.tsv +0 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/typed/__init__.py +0 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/typed/deep_group.py +0 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/typed/deep_replay.py +0 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/typed/shallow_group.py +0 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/typed/shallow_replay.py +0 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/typed/shared.py +0 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/util/__init__.py +0 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/util/dates.py +0 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/util/iterators.py +0 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/util/replays.py +0 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/util/stats.py +0 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/python_ballchasing.egg-info/dependency_links.txt +0 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/python_ballchasing.egg-info/requires.txt +0 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/python_ballchasing.egg-info/top_level.txt +0 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/scripts/get_maps.py +0 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/scripts/make_types.py +0 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/scripts/test.py +0 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/scripts/test_typed.py +0 -0
- {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/setup.cfg +0 -0
|
@@ -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
|
|
|
@@ -12,6 +12,7 @@ from requests.exceptions import ConnectionError, Timeout
|
|
|
12
12
|
|
|
13
13
|
from ballchasing.constants import GroupSortBy, SortDir, AnySeason, AnyRank, AnyReplaySortBy, AnySortDir, \
|
|
14
14
|
AnyVisibility, AnyGroupSortBy, AnyPlayerIdentification, AnyTeamIdentification, AnyMatchResult, NoneOrMore
|
|
15
|
+
from ballchasing.rate_limit import RateLimiter, RateLimitCategory, resolve_category, rate_limited
|
|
15
16
|
from ballchasing.typed import DeepReplay, ShallowReplay, DeepGroup, ShallowGroup
|
|
16
17
|
from .typed.shared import BaseGroup, BasicGroup
|
|
17
18
|
from .util.dates import to_rfc3339
|
|
@@ -45,6 +46,7 @@ class BallchasingApi:
|
|
|
45
46
|
base_url: str | None = None,
|
|
46
47
|
do_initial_ping: bool = True,
|
|
47
48
|
typed: bool = False,
|
|
49
|
+
proactive_rate_limit: bool = False,
|
|
48
50
|
):
|
|
49
51
|
"""
|
|
50
52
|
|
|
@@ -52,17 +54,22 @@ class BallchasingApi:
|
|
|
52
54
|
:param sleep_time_on_rate_limit: seconds to wait after being rate limited.
|
|
53
55
|
Default value is calculated depending on patron type.
|
|
54
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.
|
|
55
58
|
"""
|
|
56
59
|
self.auth_key = auth_key
|
|
57
60
|
self._session = sessions.Session()
|
|
58
61
|
self._ping_result = None
|
|
59
62
|
self.rate_limit_count = 0
|
|
60
63
|
self.base_url = DEFAULT_URL if base_url is None else base_url
|
|
61
|
-
if do_initial_ping:
|
|
62
|
-
self.ping()
|
|
63
64
|
self._sleep_time_on_rate_limit = sleep_time_on_rate_limit
|
|
64
65
|
self.print_on_rate_limit = print_on_rate_limit
|
|
65
66
|
self.typed = typed
|
|
67
|
+
self.rate_limiter = RateLimiter(
|
|
68
|
+
patron_type_getter=lambda: self._ping_result.get("type") if self._ping_result else None,
|
|
69
|
+
proactive=proactive_rate_limit,
|
|
70
|
+
)
|
|
71
|
+
if do_initial_ping:
|
|
72
|
+
self.ping()
|
|
66
73
|
|
|
67
74
|
@property
|
|
68
75
|
def sleep_time_on_rate_limit(self) -> float:
|
|
@@ -76,6 +83,21 @@ class BallchasingApi:
|
|
|
76
83
|
def sleep_time_on_rate_limit(self, value: float | None):
|
|
77
84
|
self._sleep_time_on_rate_limit = value
|
|
78
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()
|
|
100
|
+
|
|
79
101
|
@property
|
|
80
102
|
def steam_name(self):
|
|
81
103
|
if self._ping_result is None:
|
|
@@ -104,45 +126,58 @@ class BallchasingApi:
|
|
|
104
126
|
self,
|
|
105
127
|
url_or_endpoint: str,
|
|
106
128
|
method: str,
|
|
129
|
+
allow_status_codes: tuple[int, ...] = (),
|
|
130
|
+
category: RateLimitCategory | str | None = None,
|
|
107
131
|
**params
|
|
108
132
|
) -> Response:
|
|
109
133
|
"""
|
|
110
134
|
Helper method for all requests.
|
|
111
135
|
|
|
112
|
-
:param
|
|
136
|
+
:param url_or_endpoint: url or endpoint for request.
|
|
113
137
|
:param method: the method to use.
|
|
114
|
-
: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).
|
|
115
141
|
:return: the request result.
|
|
116
142
|
:raises ConnectionError: if the connection fails after max retries.
|
|
117
|
-
: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.
|
|
118
144
|
"""
|
|
145
|
+
cat = RateLimitCategory(category) if category else resolve_category(url_or_endpoint)
|
|
119
146
|
headers = {"Authorization": self.auth_key}
|
|
120
147
|
url = f"{self.base_url}{url_or_endpoint}" if url_or_endpoint.startswith("/") else url_or_endpoint
|
|
121
148
|
max_retries = 8
|
|
122
149
|
retries = 0
|
|
123
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
|
+
|
|
124
155
|
while True:
|
|
125
156
|
try:
|
|
126
157
|
r: Response = self._session.request(method=method, url=url, headers=headers, **params)
|
|
127
|
-
if 200 <= r.status_code < 300:
|
|
158
|
+
if 200 <= r.status_code < 300 or r.status_code in allow_status_codes:
|
|
128
159
|
return r
|
|
129
160
|
elif r.status_code == 429:
|
|
130
161
|
self.rate_limit_count += 1
|
|
131
|
-
if self.print_on_rate_limit:
|
|
132
|
-
logger.warning(f"Rate limited at {url} ({self.rate_limit_count} total rate limits)")
|
|
133
|
-
else:
|
|
134
|
-
logger.debug(f"Rate limited at {url} ({self.rate_limit_count} total rate limits)")
|
|
135
162
|
rate_limit_retries += 1
|
|
136
|
-
if rate_limit_retries > max_retries:
|
|
137
|
-
r.raise_for_status()
|
|
138
163
|
retry_after = r.headers.get("Retry-After", '0')
|
|
139
|
-
retry_after =
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
+
)
|
|
144
176
|
else:
|
|
145
|
-
|
|
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)
|
|
146
181
|
elif r.status_code in (502, 503, 504):
|
|
147
182
|
raise ConnectionError(f"Server error {r.status_code}: {r.reason or 'Transient gateway error'}")
|
|
148
183
|
else:
|
|
@@ -155,6 +190,7 @@ class BallchasingApi:
|
|
|
155
190
|
time.sleep(s)
|
|
156
191
|
retries += 1
|
|
157
192
|
|
|
193
|
+
@rate_limited(RateLimitCategory.PING)
|
|
158
194
|
def ping(self) -> dict:
|
|
159
195
|
"""
|
|
160
196
|
Use this API to:
|
|
@@ -165,7 +201,7 @@ class BallchasingApi:
|
|
|
165
201
|
This method runs automatically at initialization and the steam name and id as well as patron type are stored.
|
|
166
202
|
:return: ping response.
|
|
167
203
|
"""
|
|
168
|
-
result = self._request("/", "GET").json()
|
|
204
|
+
result = self._request("/", "GET", category=RateLimitCategory.PING).json()
|
|
169
205
|
self._ping_result = result
|
|
170
206
|
return result
|
|
171
207
|
|
|
@@ -181,7 +217,7 @@ class BallchasingApi:
|
|
|
181
217
|
return
|
|
182
218
|
|
|
183
219
|
def fetch_page(p):
|
|
184
|
-
return self._request(url, "GET", params=p).json()
|
|
220
|
+
return self._request(url, "GET", params=p, category=RateLimitCategory.LIST).json()
|
|
185
221
|
|
|
186
222
|
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
187
223
|
params["count"] = min(remaining, 200)
|
|
@@ -211,6 +247,7 @@ class BallchasingApi:
|
|
|
211
247
|
if not prefetch:
|
|
212
248
|
future = executor.submit(fetch_page, dict(params))
|
|
213
249
|
|
|
250
|
+
@rate_limited(RateLimitCategory.LIST)
|
|
214
251
|
def get_replays(
|
|
215
252
|
self,
|
|
216
253
|
*,
|
|
@@ -267,8 +304,8 @@ class BallchasingApi:
|
|
|
267
304
|
RFC3339 format, e.g. '2020-01-02T15:00:05+01:00'
|
|
268
305
|
:param count: returns at most count replays. Since the implementation uses an iterator it supports iterating
|
|
269
306
|
past the limit of 200 set by the API
|
|
270
|
-
:param sort_by: sort replays according the selected field
|
|
271
|
-
: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')
|
|
272
309
|
:param deep: whether to get full stats for each replay (will be much slower).
|
|
273
310
|
:param typed: whether to return a typed object (default is self.typed).
|
|
274
311
|
:param deduplicate: whether to deduplicate replays that seem to be the same game.
|
|
@@ -303,6 +340,7 @@ class BallchasingApi:
|
|
|
303
340
|
iterator = (ShallowReplay(**r) for r in iterator)
|
|
304
341
|
yield from iterator
|
|
305
342
|
|
|
343
|
+
@rate_limited(RateLimitCategory.CORE)
|
|
306
344
|
def get_replay(self, replay_id: str, *, typed: bool | None = None) -> dict | DeepReplay:
|
|
307
345
|
"""
|
|
308
346
|
Retrieve a given replay’s details and stats.
|
|
@@ -311,43 +349,82 @@ class BallchasingApi:
|
|
|
311
349
|
:param typed: whether to return a typed object (default is self.typed).
|
|
312
350
|
:return: the result of the GET request.
|
|
313
351
|
"""
|
|
314
|
-
result = self._request(f"/replays/{replay_id}", "GET").json()
|
|
352
|
+
result = self._request(f"/replays/{replay_id}", "GET", category=RateLimitCategory.CORE).json()
|
|
315
353
|
if typed is None:
|
|
316
354
|
typed = self.typed
|
|
317
355
|
if typed:
|
|
318
356
|
result = DeepReplay(**result)
|
|
319
357
|
return result
|
|
320
358
|
|
|
321
|
-
|
|
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:
|
|
322
369
|
"""
|
|
323
|
-
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.
|
|
324
371
|
|
|
325
372
|
:param replay_id: the replay id.
|
|
326
|
-
:param
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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)
|
|
330
388
|
def upload_replay(
|
|
331
389
|
self,
|
|
332
390
|
replay_file: str | Path | BinaryIO,
|
|
333
391
|
*,
|
|
334
392
|
visibility: AnyVisibility | None = None,
|
|
335
|
-
group: str | None = None
|
|
393
|
+
group: str | None = None,
|
|
394
|
+
raise_on_duplicate: bool = False,
|
|
336
395
|
) -> dict:
|
|
337
396
|
"""
|
|
338
397
|
Use this API to upload a replay file to ballchasing.com.
|
|
339
398
|
|
|
340
399
|
:param replay_file: replay file to upload. Can be a file path (str or Path) or a file-like object.
|
|
341
|
-
:param visibility: to set the visibility of the uploaded replay.
|
|
400
|
+
:param visibility: to set the visibility of the uploaded replay ('public', 'unlisted', 'private').
|
|
342
401
|
:param group: to upload the replay to an existing group.
|
|
343
|
-
:
|
|
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').
|
|
344
406
|
"""
|
|
345
407
|
if isinstance(replay_file, (str, Path)):
|
|
346
408
|
with open(replay_file, "rb") as f:
|
|
347
|
-
return self.upload_replay(
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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)
|
|
351
428
|
def delete_replay(self, replay_id: str) -> None:
|
|
352
429
|
"""
|
|
353
430
|
This endpoint deletes the specified replay.
|
|
@@ -355,8 +432,9 @@ class BallchasingApi:
|
|
|
355
432
|
|
|
356
433
|
:param replay_id: the replay id.
|
|
357
434
|
"""
|
|
358
|
-
self._request(f"/replays/{replay_id}", "DELETE")
|
|
435
|
+
self._request(f"/replays/{replay_id}", "DELETE", category=RateLimitCategory.CORE)
|
|
359
436
|
|
|
437
|
+
@rate_limited(RateLimitCategory.LIST)
|
|
360
438
|
def get_groups(
|
|
361
439
|
self,
|
|
362
440
|
*,
|
|
@@ -385,14 +463,14 @@ class BallchasingApi:
|
|
|
385
463
|
RFC3339 format, e.g. 2020-01-02T15:00:05+01:00
|
|
386
464
|
:param count: returns at most count groups. Since the implementation uses an iterator it supports iterating
|
|
387
465
|
past the limit of 200 set by the API
|
|
388
|
-
:param sort_by: Sort groups according the selected field.
|
|
389
|
-
: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').
|
|
390
468
|
:param deep: whether to get full stats for each group (will be much slower).
|
|
391
469
|
:param typed: whether to return a typed object (default is self.typed).
|
|
392
470
|
:param disable_prefetch: whether to disable prefetching.
|
|
393
471
|
:return: an iterator over the groups returned by the API.
|
|
394
472
|
"""
|
|
395
|
-
url = f"{self.base_url}/groups
|
|
473
|
+
url = f"{self.base_url}/groups"
|
|
396
474
|
params = {"name": name, "creator": creator, "group": group, "created-before": to_rfc3339(created_before),
|
|
397
475
|
"created-after": to_rfc3339(created_after), "count": count, "sort-by": sort_by, "sort-dir": sort_dir}
|
|
398
476
|
|
|
@@ -408,6 +486,7 @@ class BallchasingApi:
|
|
|
408
486
|
iterator = (ShallowGroup(**g) for g in iterator)
|
|
409
487
|
yield from iterator
|
|
410
488
|
|
|
489
|
+
@rate_limited(RateLimitCategory.CORE)
|
|
411
490
|
def create_group(
|
|
412
491
|
self,
|
|
413
492
|
*,
|
|
@@ -420,7 +499,7 @@ class BallchasingApi:
|
|
|
420
499
|
Use this API to create a new replay group.
|
|
421
500
|
|
|
422
501
|
:param name: the new group name.
|
|
423
|
-
: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').
|
|
424
503
|
Some tournaments (e.g. RLCS) make players use a pool of generic Steam accounts,
|
|
425
504
|
meaning the same player could end up using 2 different accounts in 2 series.
|
|
426
505
|
That's when the `by-name` comes in handy
|
|
@@ -428,13 +507,19 @@ class BallchasingApi:
|
|
|
428
507
|
Set to `by-distinct-players` if teams have a fixed roster of players for
|
|
429
508
|
every single game. In some tournaments/leagues, teams allow player rotations,
|
|
430
509
|
or a sub can replace another player, in which case use `by-player-clusters`.
|
|
431
|
-
: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
|
|
432
511
|
:return: the result of the POST request.
|
|
433
512
|
"""
|
|
434
|
-
json = {
|
|
435
|
-
|
|
436
|
-
|
|
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()
|
|
437
521
|
|
|
522
|
+
@rate_limited(RateLimitCategory.CORE)
|
|
438
523
|
def get_group(
|
|
439
524
|
self,
|
|
440
525
|
group_id: str,
|
|
@@ -448,22 +533,46 @@ class BallchasingApi:
|
|
|
448
533
|
:param typed: whether to return a typed object (default is self.typed).
|
|
449
534
|
:return: the group info with stats.
|
|
450
535
|
"""
|
|
451
|
-
result = self._request(f"/groups/{group_id}", "GET").json()
|
|
536
|
+
result = self._request(f"/groups/{group_id}", "GET", category=RateLimitCategory.CORE).json()
|
|
452
537
|
if typed is None:
|
|
453
538
|
typed = self.typed
|
|
454
539
|
if typed:
|
|
455
540
|
result = DeepGroup(**result)
|
|
456
541
|
return result
|
|
457
542
|
|
|
458
|
-
|
|
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:
|
|
459
554
|
"""
|
|
460
555
|
This endpoint can patch one or more fields of the specified group.
|
|
461
556
|
|
|
462
557
|
:param group_id: the group id
|
|
463
|
-
:param
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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)
|
|
467
576
|
def delete_group(self, group_id: str) -> None:
|
|
468
577
|
"""
|
|
469
578
|
This endpoint deletes the specified group.
|
|
@@ -471,7 +580,7 @@ class BallchasingApi:
|
|
|
471
580
|
|
|
472
581
|
:param group_id: the group id.
|
|
473
582
|
"""
|
|
474
|
-
self._request(f"/groups/{group_id}", "DELETE")
|
|
583
|
+
self._request(f"/groups/{group_id}", "DELETE", category=RateLimitCategory.CORE)
|
|
475
584
|
|
|
476
585
|
def get_group_replays(
|
|
477
586
|
self,
|
|
@@ -519,22 +628,25 @@ class BallchasingApi:
|
|
|
519
628
|
for replay in self.get_replays(group_id=group_id, deep=deep, typed=typed):
|
|
520
629
|
yield [group_id], replay
|
|
521
630
|
|
|
522
|
-
|
|
631
|
+
@rate_limited(RateLimitCategory.DOWNLOAD)
|
|
632
|
+
def download_replay(self, replay_id: str, path: str | Path) -> Path:
|
|
523
633
|
"""
|
|
524
634
|
Download a replay file.
|
|
525
635
|
|
|
526
636
|
:param replay_id: the replay id.
|
|
527
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.
|
|
528
639
|
"""
|
|
529
|
-
|
|
530
|
-
if
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
|
536
648
|
|
|
537
|
-
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):
|
|
538
650
|
"""
|
|
539
651
|
Download an entire group.
|
|
540
652
|
|
|
@@ -542,22 +654,24 @@ class BallchasingApi:
|
|
|
542
654
|
:param folder: the folder in which to create the group folder.
|
|
543
655
|
:param keep_tree_structure: whether to create new folders for child groups.
|
|
544
656
|
"""
|
|
545
|
-
folder =
|
|
657
|
+
folder = Path(folder) / group_id
|
|
546
658
|
if keep_tree_structure:
|
|
547
|
-
|
|
659
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
548
660
|
for child_group in self.get_groups(group=group_id):
|
|
549
661
|
self.download_group(child_group["id"], folder, keep_tree_structure=True)
|
|
550
662
|
for replay in self.get_replays(group_id=group_id):
|
|
551
663
|
self.download_replay(replay["id"], folder)
|
|
552
664
|
else:
|
|
665
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
553
666
|
for replay in self.get_group_replays(group_id):
|
|
554
667
|
self.download_replay(replay["id"], folder)
|
|
555
668
|
|
|
669
|
+
@rate_limited(RateLimitCategory.CORE)
|
|
556
670
|
def get_maps(self):
|
|
557
671
|
"""
|
|
558
672
|
Use this API to get the list of map codes to map names (map as in stadium).
|
|
559
673
|
"""
|
|
560
|
-
res = self._request("/maps", "GET").json()
|
|
674
|
+
res = self._request("/maps", "GET", category=RateLimitCategory.CORE).json()
|
|
561
675
|
return res
|
|
562
676
|
|
|
563
677
|
def get_stats(self, replay: dict | str):
|