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 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, ConnectionError
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
- if sleep_time_on_rate_limit is None:
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 url: url or endpoint for request.
136
+ :param url_or_endpoint: url or endpoint for request.
98
137
  :param method: the method to use.
99
- :param params: parameters for GET request.
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 429.
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
- if self.print_on_rate_limit:
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 = int(retry_after) if retry_after.isdigit() else None
119
- if retry_after: # integer > 0
120
- time.sleep(retry_after)
121
- elif self.sleep_time_on_rate_limit:
122
- time.sleep(self.sleep_time_on_rate_limit)
123
- elif r.status_code == 504:
124
- raise ConnectionError("Gateway Timeout. The server did not respond in time.")
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
- print(f"Connection error, trying again in {s} seconds...")
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["count"]
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["list"][:min(remaining, 200)]
228
+ batch = d.get("list", [])[:min(remaining, 200)]
168
229
  remaining -= len(batch)
169
230
 
170
- has_next = "next" in d and remaining > 0
231
+ next_url = d.get("next")
232
+ has_next = bool(next_url) and remaining > 0
171
233
  if has_next:
172
- next_url = d["next"]
173
- params["after"] = parse_qs(urlparse(next_url).query)["after"][0]
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
- def patch_replay(self, replay_id: str, **params) -> None:
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 params: parameters for the PATCH request.
298
- """
299
- self._request(f"/replays/{replay_id}", "PATCH", json=params)
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
- :return: the result of the POST request.
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(f, visibility=visibility, group=group)
319
- return self._request(f"/v2/upload", "POST", files={"file": replay_file},
320
- params={"group": group, "visibility": visibility}).json()
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 = {"name": name, "player_identification": player_identification,
406
- "team_identification": team_identification, "parent": parent}
407
- return self._request(f"/groups", "POST", json=json).json()
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
- def patch_group(self, group_id: str, **params) -> None:
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 params: parameters for the PATCH request.
435
- """
436
- self._request(f"/groups/{group_id}", "PATCH", json=params)
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
- def download_replay(self, replay_id: str, path: str):
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
- r = self._request(f"/replays/{replay_id}/file", "GET")
501
- if os.path.isdir(path):
502
- # If path is a directory, use the replay id as the filename
503
- filename = f"{replay_id}.replay"
504
- path = os.path.join(path, filename)
505
- with open(path, "wb") as f:
506
- f.write(r.content)
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 = os.path.join(folder, group_id)
657
+ folder = Path(folder) / group_id
517
658
  if keep_tree_structure:
518
- os.makedirs(folder, exist_ok=True)
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):