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.
Files changed (32) hide show
  1. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/PKG-INFO +1 -1
  2. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/__init__.py +1 -0
  3. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/api.py +178 -64
  4. python_ballchasing-0.5.4/ballchasing/rate_limit.py +227 -0
  5. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/pyproject.toml +1 -1
  6. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/python_ballchasing.egg-info/PKG-INFO +1 -1
  7. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/python_ballchasing.egg-info/SOURCES.txt +1 -0
  8. python_ballchasing-0.5.4/tests/test_fixes.py +439 -0
  9. python_ballchasing-0.5.2/tests/test_fixes.py +0 -246
  10. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/LICENSE +0 -0
  11. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/README.md +0 -0
  12. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/constants.py +0 -0
  13. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/stats_info.tsv +0 -0
  14. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/typed/__init__.py +0 -0
  15. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/typed/deep_group.py +0 -0
  16. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/typed/deep_replay.py +0 -0
  17. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/typed/shallow_group.py +0 -0
  18. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/typed/shallow_replay.py +0 -0
  19. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/typed/shared.py +0 -0
  20. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/util/__init__.py +0 -0
  21. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/util/dates.py +0 -0
  22. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/util/iterators.py +0 -0
  23. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/util/replays.py +0 -0
  24. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/ballchasing/util/stats.py +0 -0
  25. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/python_ballchasing.egg-info/dependency_links.txt +0 -0
  26. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/python_ballchasing.egg-info/requires.txt +0 -0
  27. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/python_ballchasing.egg-info/top_level.txt +0 -0
  28. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/scripts/get_maps.py +0 -0
  29. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/scripts/make_types.py +0 -0
  30. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/scripts/test.py +0 -0
  31. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/scripts/test_typed.py +0 -0
  32. {python_ballchasing-0.5.2 → python_ballchasing-0.5.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-ballchasing
3
- Version: 0.5.2
3
+ Version: 0.5.4
4
4
  Summary: A Python wrapper around the Ballchasing API
5
5
  Author-email: Rolv-Arild Braaten <rolv_arild@hotmail.com>
6
6
  License: MIT License
@@ -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 url: url or endpoint for request.
136
+ :param url_or_endpoint: url or endpoint for request.
113
137
  :param method: the method to use.
114
- :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).
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 429.
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 = int(retry_after) if retry_after.isdigit() else None
140
- if retry_after: # integer > 0
141
- time.sleep(retry_after)
142
- elif self.sleep_time_on_rate_limit:
143
- time.sleep(self.sleep_time_on_rate_limit)
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
- time.sleep(max(1.0, float(2 ** (rate_limit_retries - 1))))
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
- 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:
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 params: parameters for the PATCH request.
327
- """
328
- self._request(f"/replays/{replay_id}", "PATCH", json=params)
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
- :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').
344
406
  """
345
407
  if isinstance(replay_file, (str, Path)):
346
408
  with open(replay_file, "rb") as f:
347
- return self.upload_replay(f, visibility=visibility, group=group)
348
- return self._request(f"/v2/upload", "POST", files={"file": replay_file},
349
- params={"group": group, "visibility": visibility}).json()
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 = {"name": name, "player_identification": player_identification,
435
- "team_identification": team_identification, "parent": parent}
436
- 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()
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
- 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:
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 params: parameters for the PATCH request.
464
- """
465
- self._request(f"/groups/{group_id}", "PATCH", json=params)
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
- 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:
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
- r = self._request(f"/replays/{replay_id}/file", "GET")
530
- if os.path.isdir(path):
531
- # If path is a directory, use the replay id as the filename
532
- filename = f"{replay_id}.replay"
533
- path = os.path.join(path, filename)
534
- with open(path, "wb") as f:
535
- 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
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 = os.path.join(folder, group_id)
657
+ folder = Path(folder) / group_id
546
658
  if keep_tree_structure:
547
- os.makedirs(folder, exist_ok=True)
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):