python-ballchasing 0.3.0__py3-none-any.whl → 0.4.1__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
@@ -65,3 +65,10 @@ from .constants import (
65
65
  TeamIdentification,
66
66
  Map
67
67
  )
68
+
69
+ from .typed import (
70
+ ShallowReplay,
71
+ ShallowGroup,
72
+ DeepReplay,
73
+ DeepGroup
74
+ )
ballchasing/api.py CHANGED
@@ -2,14 +2,16 @@ import os
2
2
  import time
3
3
  from datetime import datetime
4
4
  from pathlib import Path
5
- from typing import Optional, Iterator, Union, List, BinaryIO
5
+ from typing import Optional, Union, List, BinaryIO, Iterator
6
6
  from urllib.parse import parse_qs, urlparse
7
7
 
8
- from requests import sessions, Response, ConnectionError
8
+ from requests import sessions, Response, ConnectionError, HTTPError
9
9
 
10
10
  from ballchasing.constants import GroupSortBy, SortDir, AnyPlaylist, AnyMap, AnySeason, AnyRank, AnyReplaySortBy, \
11
11
  AnySortDir, AnyVisibility, AnyGroupSortBy, AnyPlayerIdentification, AnyTeamIdentification, AnyMatchResult
12
- from .util import rfc3339, parse_replay_stats
12
+ from ballchasing.typed import DeepReplay, ShallowReplay, DeepGroup, ShallowGroup
13
+ from .typed.shared import BaseGroup, BasicGroup
14
+ from .util import to_rfc3339, parse_replay_stats
13
15
 
14
16
  DEFAULT_URL = "https://ballchasing.com/api"
15
17
 
@@ -22,10 +24,12 @@ class BallchasingApi:
22
24
  def __init__(
23
25
  self,
24
26
  auth_key: str,
27
+ *,
25
28
  sleep_time_on_rate_limit: Optional[float] = None,
26
29
  print_on_rate_limit: bool = False,
27
30
  base_url=None,
28
- do_initial_ping=True
31
+ do_initial_ping=True,
32
+ typed=False,
29
33
  ):
30
34
  """
31
35
 
@@ -52,6 +56,7 @@ class BallchasingApi:
52
56
  else:
53
57
  self.sleep_time_on_rate_limit = sleep_time_on_rate_limit
54
58
  self.print_on_rate_limit = print_on_rate_limit
59
+ self.typed = typed
55
60
 
56
61
  @property
57
62
  def steam_name(self):
@@ -136,8 +141,36 @@ class BallchasingApi:
136
141
  self._ping_result = result
137
142
  return result
138
143
 
144
+ def _iterable_from_request(self, url, params):
145
+ # Shared by get_replays and get_groups
146
+ remaining = params["count"]
147
+ # return_length = True
148
+ while remaining > 0:
149
+ request_count = min(remaining, 200)
150
+ params["count"] = request_count
151
+ try:
152
+ d = self._request(url, "GET", params=params).json()
153
+ except HTTPError as e:
154
+ if e.response.status_code == 504:
155
+ # Gateway Timeout, retry
156
+ time.sleep(5)
157
+ continue
158
+ else:
159
+ raise e
160
+
161
+ batch = d["list"][:request_count]
162
+ yield from batch
163
+
164
+ if "next" not in d:
165
+ break
166
+
167
+ next_url = d["next"]
168
+ remaining -= len(batch)
169
+ params["after"] = parse_qs(urlparse(next_url).query)["after"][0]
170
+
139
171
  def get_replays(
140
172
  self,
173
+ *,
141
174
  title: Optional[str] = None,
142
175
  player_name: Optional[Union[str, List[str]]] = None,
143
176
  player_id: Optional[Union[str, List[str]]] = None,
@@ -157,8 +190,9 @@ class BallchasingApi:
157
190
  count: int = 150,
158
191
  sort_by: Optional[AnyReplaySortBy] = None,
159
192
  sort_dir: AnySortDir = SortDir.DESCENDING,
160
- deep: bool = False
161
- ) -> Iterator[dict]:
193
+ deep: bool = False,
194
+ typed: Optional[bool] = None,
195
+ ) -> Iterator[Union[dict, ShallowReplay, DeepReplay]]:
162
196
  """
163
197
  This endpoint lets you filter and retrieve replays. The implementation returns an iterator.
164
198
 
@@ -191,42 +225,41 @@ class BallchasingApi:
191
225
  :param sort_by: sort replays according the selected field
192
226
  :param sort_dir: sort direction
193
227
  :param deep: whether to get full stats for each replay (will be much slower).
228
+ :param typed: whether to return a typed object (default is self.typed).
194
229
  :return: an iterator over the replays returned by the API.
195
230
  """
196
231
  url = f"{self.base_url}/replays"
197
232
  params = {"title": title, "player-name": player_name, "player-id": player_id, "playlist": playlist,
198
233
  "season": season, "match-result": match_result, "min-rank": min_rank, "max-rank": max_rank,
199
234
  "pro": pro, "uploader": uploader, "group": group_id, "map": map_id,
200
- "created-before": rfc3339(created_before), "created-after": rfc3339(created_after),
201
- "replay-date-after": rfc3339(replay_after), "replay-date-before": rfc3339(replay_before),
202
- "sort-by": sort_by, "sort-dir": sort_dir}
203
- left = count
204
- while left > 0:
205
- request_count = min(left, 200)
206
- params["count"] = request_count
207
- d = self._request(url, "GET", params=params).json()
235
+ "created-before": to_rfc3339(created_before), "created-after": to_rfc3339(created_after),
236
+ "replay-date-after": to_rfc3339(replay_after), "replay-date-before": to_rfc3339(replay_before),
237
+ "count": count, "sort-by": sort_by, "sort-dir": sort_dir}
208
238
 
209
- batch = d["list"][:request_count]
210
- if not deep:
211
- yield from batch
212
- else:
213
- yield from (self.get_replay(r["id"]) for r in batch)
239
+ if typed is None:
240
+ typed = self.typed
214
241
 
215
- if "next" not in d:
216
- break
242
+ iterator = self._iterable_from_request(url, params)
243
+ if deep:
244
+ iterator = (self.get_replay(r["id"], typed=typed) for r in iterator)
245
+ elif typed:
246
+ iterator = (ShallowReplay(**r) for r in iterator)
247
+ yield from iterator
217
248
 
218
- next_url = d["next"]
219
- left -= len(batch)
220
- params["after"] = parse_qs(urlparse(next_url).query)["after"][0]
221
-
222
- def get_replay(self, replay_id: str) -> dict:
249
+ def get_replay(self, replay_id: str, *, typed: Optional[bool] = None) -> Union[dict, DeepReplay]:
223
250
  """
224
251
  Retrieve a given replay’s details and stats.
225
252
 
226
253
  :param replay_id: the replay id.
254
+ :param typed: whether to return a typed object (default is self.typed).
227
255
  :return: the result of the GET request.
228
256
  """
229
- return self._request(f"/replays/{replay_id}", "GET").json()
257
+ result = self._request(f"/replays/{replay_id}", "GET").json()
258
+ if typed is None:
259
+ typed = self.typed
260
+ if typed:
261
+ result = DeepReplay(**result)
262
+ return result
230
263
 
231
264
  def patch_replay(self, replay_id: str, **params) -> None:
232
265
  """
@@ -240,6 +273,7 @@ class BallchasingApi:
240
273
  def upload_replay(
241
274
  self,
242
275
  replay_file: Union[str, Path, BinaryIO],
276
+ *,
243
277
  visibility: Optional[AnyVisibility] = None,
244
278
  group: Optional[str] = None
245
279
  ) -> dict:
@@ -253,7 +287,7 @@ class BallchasingApi:
253
287
  """
254
288
  if isinstance(replay_file, (str, Path)):
255
289
  with open(replay_file, "rb") as f:
256
- return self.upload_replay(f, visibility, group)
290
+ return self.upload_replay(f, visibility=visibility, group=group)
257
291
  return self._request(f"/v2/upload", "POST", files={"file": replay_file},
258
292
  params={"group": group, "visibility": visibility}).json()
259
293
 
@@ -268,6 +302,7 @@ class BallchasingApi:
268
302
 
269
303
  def get_groups(
270
304
  self,
305
+ *,
271
306
  name: Optional[str] = None,
272
307
  creator: Optional[str] = None,
273
308
  group: Optional[str] = None,
@@ -275,8 +310,10 @@ class BallchasingApi:
275
310
  created_after: Optional[Union[str, datetime]] = None,
276
311
  count: int = 200,
277
312
  sort_by: AnyGroupSortBy = GroupSortBy.CREATED,
278
- sort_dir: AnySortDir = SortDir.DESCENDING
279
- ) -> Iterator[dict]:
313
+ sort_dir: AnySortDir = SortDir.DESCENDING,
314
+ deep: bool = False,
315
+ typed: bool = None,
316
+ ) -> Iterator[Union[dict, ShallowGroup, DeepGroup]]:
280
317
  """
281
318
  This endpoint lets you filter and retrieve replay groups.
282
319
 
@@ -292,30 +329,25 @@ class BallchasingApi:
292
329
  past the limit of 200 set by the API
293
330
  :param sort_by: Sort groups according the selected field.
294
331
  :param sort_dir: Sort direction.
332
+ :param deep: whether to get full stats for each group (will be much slower).
333
+ :param typed: whether to return a typed object (default is self.typed).
295
334
  :return: an iterator over the groups returned by the API.
296
335
  """
297
336
  url = f"{self.base_url}/groups/"
298
- params = {"name": name, "creator": creator, "group": group, "created-before": rfc3339(created_before),
299
- "created-after": rfc3339(created_after), "sort-by": sort_by, "sort-dir": sort_dir}
300
-
301
- left = count
302
- while left > 0:
303
- request_count = min(left, 200)
304
- params["count"] = request_count
305
- d = self._request(url, "GET", params=params).json()
306
-
307
- batch = d["list"][:request_count]
308
- yield from batch
309
-
310
- if "next" not in d:
311
- break
312
-
313
- next_url = d["next"]
314
- left -= len(batch)
315
- params["after"] = parse_qs(urlparse(next_url).query)["after"][0]
337
+ params = {"name": name, "creator": creator, "group": group, "created-before": to_rfc3339(created_before),
338
+ "created-after": to_rfc3339(created_after), "count": count, "sort-by": sort_by, "sort-dir": sort_dir}
339
+ iterator = self._iterable_from_request(url, params)
340
+ if typed is None:
341
+ typed = self.typed
342
+ if deep:
343
+ iterator = (self.get_group(g["id"], typed=typed) for g in iterator)
344
+ elif typed:
345
+ iterator = (ShallowGroup(**g) for g in iterator)
346
+ yield from iterator
316
347
 
317
348
  def create_group(
318
349
  self,
350
+ *,
319
351
  name: str,
320
352
  player_identification: AnyPlayerIdentification,
321
353
  team_identification: AnyTeamIdentification,
@@ -340,14 +372,25 @@ class BallchasingApi:
340
372
  "team_identification": team_identification, "parent": parent}
341
373
  return self._request(f"/groups", "POST", json=json).json()
342
374
 
343
- def get_group(self, group_id: str) -> dict:
375
+ def get_group(
376
+ self,
377
+ group_id: str,
378
+ *,
379
+ typed: Optional[bool] = None
380
+ ) -> Union[dict, DeepGroup]:
344
381
  """
345
382
  This endpoint retrieves a specific replay group info and stats given its id.
346
383
 
347
384
  :param group_id: the group id.
385
+ :param typed: whether to return a typed object (default is self.typed).
348
386
  :return: the group info with stats.
349
387
  """
350
- return self._request(f"/groups/{group_id}", "GET").json()
388
+ result = self._request(f"/groups/{group_id}", "GET").json()
389
+ if typed is None:
390
+ typed = self.typed
391
+ if typed:
392
+ result = DeepGroup(**result)
393
+ return result
351
394
 
352
395
  def patch_group(self, group_id: str, **params) -> None:
353
396
  """
@@ -367,46 +410,79 @@ class BallchasingApi:
367
410
  """
368
411
  self._request(f"/groups/{group_id}", "DELETE")
369
412
 
370
- def get_group_replays(self, group_id: str, deep: bool = False) -> Iterator[dict]:
413
+ def get_group_replays(
414
+ self,
415
+ group: Union[str, dict, BasicGroup],
416
+ *,
417
+ deep: bool = False,
418
+ typed: Optional[bool] = None
419
+ ) -> Iterator[Union[dict, ShallowReplay, DeepReplay]]:
371
420
  """
372
421
  Finds all replays in a group, including child groups.
373
422
 
374
- :param group_id: the base group id.
423
+ :param group: the base group id, group dict, or BaseGroup object.
375
424
  :param deep: whether or not to get full stats for each replay (will be much slower).
425
+ :param typed: whether to return a typed object (default is self.typed).
376
426
  :return: an iterator over all the replays in the group.
377
427
  """
378
- child_groups = self.get_groups(group=group_id)
379
- for child in child_groups:
380
- for replay in self.get_group_replays(child["id"], deep):
381
- yield replay
382
- for replay in self.get_replays(group_id=group_id, deep=deep):
428
+ for path in self.get_group_tree(group, deep=deep, typed=typed):
429
+ group, replay = path
383
430
  yield replay
384
431
 
385
- def download_replay(self, replay_id: str, folder: str):
432
+ def get_group_tree(
433
+ self,
434
+ group: Union[str, dict, BaseGroup],
435
+ *,
436
+ deep: bool = False,
437
+ typed: Optional[bool] = None
438
+ ):
439
+ """
440
+ Finds all replays in a group, and includes the groups leading up to the replays.
441
+ :param group: the group id or a group dict.
442
+ :param deep: whether to get full stats for each replay and group (will be much slower).
443
+ :param typed: whether to return a typed object (default is self.typed).
444
+ """
445
+ if isinstance(group, str):
446
+ group = self.get_group(group)
447
+ if isinstance(group, BasicGroup):
448
+ group_id = group.id
449
+ else:
450
+ group_id = group["id"]
451
+ child_groups = self.get_groups(group=group_id, typed=typed)
452
+ for child in child_groups:
453
+ for path in self.get_group_tree(child, deep=deep, typed=typed):
454
+ yield group_id, *path
455
+ for replay in self.get_replays(group_id=group_id, deep=deep, typed=typed):
456
+ yield group_id, replay
457
+
458
+ def download_replay(self, replay_id: str, path: str):
386
459
  """
387
460
  Download a replay file.
388
461
 
389
462
  :param replay_id: the replay id.
390
- :param folder: the folder to download into.
463
+ :param path: the path to download the replay to. Can be a file path or a directory.
391
464
  """
392
465
  r = self._request(f"/replays/{replay_id}/file", "GET")
393
- with open(f"{folder}/{replay_id}.replay", "wb") as f:
394
- for ch in r:
395
- f.write(ch)
466
+ if os.path.isdir(path):
467
+ # If path is a directory, use the replay id as the filename
468
+ filename = f"{replay_id}.replay"
469
+ path = os.path.join(path, filename)
470
+ with open(path, "wb") as f:
471
+ f.write(r.content)
396
472
 
397
- def download_group(self, group_id: str, folder: str, recursive=True):
473
+ def download_group(self, group_id: str, folder: str, *, keep_tree_structure=True):
398
474
  """
399
475
  Download an entire group.
400
476
 
401
477
  :param group_id: the base group id.
402
478
  :param folder: the folder in which to create the group folder.
403
- :param recursive: whether to create new folders for child groups.
479
+ :param keep_tree_structure: whether to create new folders for child groups.
404
480
  """
405
481
  folder = os.path.join(folder, group_id)
406
- if recursive:
482
+ if keep_tree_structure:
407
483
  os.makedirs(folder, exist_ok=True)
408
484
  for child_group in self.get_groups(group=group_id):
409
- self.download_group(child_group["id"], folder, True)
485
+ self.download_group(child_group["id"], folder, keep_tree_structure=True)
410
486
  for replay in self.get_replays(group_id=group_id):
411
487
  self.download_replay(replay["id"], folder)
412
488
  else:
@@ -429,9 +505,9 @@ class BallchasingApi:
429
505
  """
430
506
 
431
507
  if isinstance(replay, str):
432
- replay = self.get_replay(replay)
508
+ replay = self.get_replay(replay, typed=False)
433
509
  elif isinstance(replay, dict) and "title" not in replay:
434
- replay = self.get_replay(replay["id"])
510
+ replay = self.get_replay(replay["id"], typed=False)
435
511
 
436
512
  stats = parse_replay_stats(replay)
437
513
  return stats
@@ -0,0 +1,5 @@
1
+ # Imports for the objects meant to be initialized directly
2
+ from ballchasing.typed.deep_group import DeepGroup
3
+ from ballchasing.typed.deep_replay import DeepReplay
4
+ from ballchasing.typed.shallow_group import ShallowGroup
5
+ from ballchasing.typed.shallow_replay import ShallowReplay