python-ballchasing 0.2.0__py3-none-any.whl → 0.4.0__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
@@ -1,14 +1,17 @@
1
1
  import os
2
2
  import time
3
3
  from datetime import datetime
4
- from typing import Optional, Iterator, Union, List
4
+ from pathlib import Path
5
+ from typing import Optional, Union, List, BinaryIO, Iterator
5
6
  from urllib.parse import parse_qs, urlparse
6
7
 
7
- from requests import sessions, Response, ConnectionError
8
+ from requests import sessions, Response, ConnectionError, HTTPError
8
9
 
9
10
  from ballchasing.constants import GroupSortBy, SortDir, AnyPlaylist, AnyMap, AnySeason, AnyRank, AnyReplaySortBy, \
10
11
  AnySortDir, AnyVisibility, AnyGroupSortBy, AnyPlayerIdentification, AnyTeamIdentification, AnyMatchResult
11
- 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
12
15
 
13
16
  DEFAULT_URL = "https://ballchasing.com/api"
14
17
 
@@ -18,12 +21,16 @@ class BallchasingApi:
18
21
  Class for communication with ballchasing.com API (https://ballchasing.com/doc/api)
19
22
  """
20
23
 
21
- def __init__(self,
22
- auth_key: str,
23
- sleep_time_on_rate_limit: Optional[float] = None,
24
- print_on_rate_limit: bool = False,
25
- base_url=None,
26
- do_initial_ping=True):
24
+ def __init__(
25
+ self,
26
+ auth_key: str,
27
+ *,
28
+ sleep_time_on_rate_limit: Optional[float] = None,
29
+ print_on_rate_limit: bool = False,
30
+ base_url=None,
31
+ do_initial_ping=True,
32
+ typed=False,
33
+ ):
27
34
  """
28
35
 
29
36
  :param auth_key: authentication key for API calls.
@@ -49,6 +56,7 @@ class BallchasingApi:
49
56
  else:
50
57
  self.sleep_time_on_rate_limit = sleep_time_on_rate_limit
51
58
  self.print_on_rate_limit = print_on_rate_limit
59
+ self.typed = typed
52
60
 
53
61
  @property
54
62
  def steam_name(self):
@@ -74,11 +82,12 @@ class BallchasingApi:
74
82
  self.ping()
75
83
  return self._ping_result.get("quota")
76
84
 
77
- def _request(self,
78
- url_or_endpoint: str,
79
- method: str,
80
- **params
81
- ) -> Response:
85
+ def _request(
86
+ self,
87
+ url_or_endpoint: str,
88
+ method: str,
89
+ **params
90
+ ) -> Response:
82
91
  """
83
92
  Helper method for all requests.
84
93
 
@@ -92,7 +101,8 @@ class BallchasingApi:
92
101
  headers = {"Authorization": self.auth_key}
93
102
  url = f"{self.base_url}{url_or_endpoint}" if url_or_endpoint.startswith("/") else url_or_endpoint
94
103
  max_retries = 8
95
- for retries in range(max_retries):
104
+ retries = 0
105
+ while True:
96
106
  try:
97
107
  r: Response = self._session.request(method=method, url=url, headers=headers, **params)
98
108
  if 200 <= r.status_code < 300:
@@ -108,13 +118,14 @@ class BallchasingApi:
108
118
  elif self.sleep_time_on_rate_limit:
109
119
  time.sleep(self.sleep_time_on_rate_limit)
110
120
  else:
111
- r.raise_for_status() # Raise an error for any other status code
121
+ r.raise_for_status() # Raise an error for any other status code'
112
122
  except ConnectionError as e:
113
123
  if retries >= max_retries - 1:
114
124
  raise e
115
125
  s = 2 ** retries
116
126
  print(f"Connection error, trying again in {s} seconds...")
117
127
  time.sleep(s)
128
+ retries += 1
118
129
 
119
130
  def ping(self) -> dict:
120
131
  """
@@ -130,28 +141,56 @@ class BallchasingApi:
130
141
  self._ping_result = result
131
142
  return result
132
143
 
133
- def get_replays(self,
134
- title: Optional[str] = None,
135
- player_name: Optional[Union[str, List[str]]] = None,
136
- player_id: Optional[Union[str, List[str]]] = None,
137
- playlist: Optional[Union[AnyPlaylist, List[AnyPlaylist]]] = None,
138
- season: Optional[Union[AnySeason, List[AnySeason]]] = None,
139
- match_result: Optional[Union[AnyMatchResult, List[AnyMatchResult]]] = None,
140
- min_rank: Optional[AnyRank] = None,
141
- max_rank: Optional[AnyRank] = None,
142
- pro: Optional[bool] = None,
143
- uploader: Optional[str] = None,
144
- group_id: Optional[Union[str, List[str]]] = None,
145
- map_id: Optional[Union[AnyMap, List[AnyMap]]] = None,
146
- created_before: Optional[Union[str, datetime]] = None,
147
- created_after: Optional[Union[str, datetime]] = None,
148
- replay_after: Optional[Union[str, datetime]] = None,
149
- replay_before: Optional[Union[str, datetime]] = None,
150
- count: int = 150,
151
- sort_by: Optional[AnyReplaySortBy] = None,
152
- sort_dir: AnySortDir = SortDir.DESCENDING,
153
- deep: bool = False
154
- ) -> Iterator[dict]:
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
+
159
+ batch = d["list"][:request_count]
160
+ yield from batch
161
+
162
+ if "next" not in d:
163
+ break
164
+
165
+ next_url = d["next"]
166
+ remaining -= len(batch)
167
+ params["after"] = parse_qs(urlparse(next_url).query)["after"][0]
168
+
169
+ def get_replays(
170
+ self,
171
+ *,
172
+ title: Optional[str] = None,
173
+ player_name: Optional[Union[str, List[str]]] = None,
174
+ player_id: Optional[Union[str, List[str]]] = None,
175
+ playlist: Optional[Union[AnyPlaylist, List[AnyPlaylist]]] = None,
176
+ season: Optional[Union[AnySeason, List[AnySeason]]] = None,
177
+ match_result: Optional[Union[AnyMatchResult, List[AnyMatchResult]]] = None,
178
+ min_rank: Optional[AnyRank] = None,
179
+ max_rank: Optional[AnyRank] = None,
180
+ pro: Optional[bool] = None,
181
+ uploader: Optional[str] = None,
182
+ group_id: Optional[Union[str, List[str]]] = None,
183
+ map_id: Optional[Union[AnyMap, List[AnyMap]]] = None,
184
+ created_before: Optional[Union[str, datetime]] = None,
185
+ created_after: Optional[Union[str, datetime]] = None,
186
+ replay_after: Optional[Union[str, datetime]] = None,
187
+ replay_before: Optional[Union[str, datetime]] = None,
188
+ count: int = 150,
189
+ sort_by: Optional[AnyReplaySortBy] = None,
190
+ sort_dir: AnySortDir = SortDir.DESCENDING,
191
+ deep: bool = False,
192
+ typed: Optional[bool] = None,
193
+ ) -> Iterator[Union[dict, ShallowReplay, DeepReplay]]:
155
194
  """
156
195
  This endpoint lets you filter and retrieve replays. The implementation returns an iterator.
157
196
 
@@ -184,42 +223,41 @@ class BallchasingApi:
184
223
  :param sort_by: sort replays according the selected field
185
224
  :param sort_dir: sort direction
186
225
  :param deep: whether to get full stats for each replay (will be much slower).
226
+ :param typed: whether to return a typed object (default is self.typed).
187
227
  :return: an iterator over the replays returned by the API.
188
228
  """
189
229
  url = f"{self.base_url}/replays"
190
230
  params = {"title": title, "player-name": player_name, "player-id": player_id, "playlist": playlist,
191
231
  "season": season, "match-result": match_result, "min-rank": min_rank, "max-rank": max_rank,
192
232
  "pro": pro, "uploader": uploader, "group": group_id, "map": map_id,
193
- "created-before": rfc3339(created_before), "created-after": rfc3339(created_after),
194
- "replay-date-after": rfc3339(replay_after), "replay-date-before": rfc3339(replay_before),
195
- "sort-by": sort_by, "sort-dir": sort_dir}
196
- left = count
197
- while left > 0:
198
- request_count = min(left, 200)
199
- params["count"] = request_count
200
- d = self._request(url, "GET", params=params).json()
233
+ "created-before": to_rfc3339(created_before), "created-after": to_rfc3339(created_after),
234
+ "replay-date-after": to_rfc3339(replay_after), "replay-date-before": to_rfc3339(replay_before),
235
+ "count": count, "sort-by": sort_by, "sort-dir": sort_dir}
201
236
 
202
- batch = d["list"][:request_count]
203
- if not deep:
204
- yield from batch
205
- else:
206
- yield from (self.get_replay(r["id"]) for r in batch)
237
+ if typed is None:
238
+ typed = self.typed
207
239
 
208
- if "next" not in d:
209
- break
210
-
211
- next_url = d["next"]
212
- left -= len(batch)
213
- params["after"] = parse_qs(urlparse(next_url).query)["after"][0]
240
+ iterator = self._iterable_from_request(url, params)
241
+ if deep:
242
+ iterator = (self.get_replay(r["id"], typed=typed) for r in iterator)
243
+ elif typed:
244
+ iterator = (ShallowReplay(**r) for r in iterator)
245
+ yield from iterator
214
246
 
215
- def get_replay(self, replay_id: str) -> dict:
247
+ def get_replay(self, replay_id: str, *, typed: Optional[bool] = None) -> Union[dict, DeepReplay]:
216
248
  """
217
249
  Retrieve a given replay’s details and stats.
218
250
 
219
251
  :param replay_id: the replay id.
252
+ :param typed: whether to return a typed object (default is self.typed).
220
253
  :return: the result of the GET request.
221
254
  """
222
- return self._request(f"/replays/{replay_id}", "GET").json()
255
+ result = self._request(f"/replays/{replay_id}", "GET").json()
256
+ if typed is None:
257
+ typed = self.typed
258
+ if typed:
259
+ result = DeepReplay(**result)
260
+ return result
223
261
 
224
262
  def patch_replay(self, replay_id: str, **params) -> None:
225
263
  """
@@ -230,18 +268,24 @@ class BallchasingApi:
230
268
  """
231
269
  self._request(f"/replays/{replay_id}", "PATCH", json=params)
232
270
 
233
- def upload_replay(self,
234
- replay_file,
235
- visibility: Optional[AnyVisibility] = None,
236
- group: Optional[str] = None) -> dict:
271
+ def upload_replay(
272
+ self,
273
+ replay_file: Union[str, Path, BinaryIO],
274
+ *,
275
+ visibility: Optional[AnyVisibility] = None,
276
+ group: Optional[str] = None
277
+ ) -> dict:
237
278
  """
238
279
  Use this API to upload a replay file to ballchasing.com.
239
280
 
240
- :param replay_file: replay file to upload.
281
+ :param replay_file: replay file to upload. Can be a file path (str or Path) or a file-like object.
241
282
  :param visibility: to set the visibility of the uploaded replay.
242
283
  :param group: to upload the replay to an existing group.
243
284
  :return: the result of the POST request.
244
285
  """
286
+ if isinstance(replay_file, (str, Path)):
287
+ with open(replay_file, "rb") as f:
288
+ return self.upload_replay(f, visibility=visibility, group=group)
245
289
  return self._request(f"/v2/upload", "POST", files={"file": replay_file},
246
290
  params={"group": group, "visibility": visibility}).json()
247
291
 
@@ -254,16 +298,20 @@ class BallchasingApi:
254
298
  """
255
299
  self._request(f"/replays/{replay_id}", "DELETE")
256
300
 
257
- def get_groups(self,
258
- name: Optional[str] = None,
259
- creator: Optional[str] = None,
260
- group: Optional[str] = None,
261
- created_before: Optional[Union[str, datetime]] = None,
262
- created_after: Optional[Union[str, datetime]] = None,
263
- count: int = 200,
264
- sort_by: AnyGroupSortBy = GroupSortBy.CREATED,
265
- sort_dir: AnySortDir = SortDir.DESCENDING
266
- ) -> Iterator[dict]:
301
+ def get_groups(
302
+ self,
303
+ *,
304
+ name: Optional[str] = None,
305
+ creator: Optional[str] = None,
306
+ group: Optional[str] = None,
307
+ created_before: Optional[Union[str, datetime]] = None,
308
+ created_after: Optional[Union[str, datetime]] = None,
309
+ count: int = 200,
310
+ sort_by: AnyGroupSortBy = GroupSortBy.CREATED,
311
+ sort_dir: AnySortDir = SortDir.DESCENDING,
312
+ deep: bool = False,
313
+ typed: bool = None,
314
+ ) -> Iterator[Union[dict, ShallowGroup, DeepGroup]]:
267
315
  """
268
316
  This endpoint lets you filter and retrieve replay groups.
269
317
 
@@ -279,34 +327,30 @@ class BallchasingApi:
279
327
  past the limit of 200 set by the API
280
328
  :param sort_by: Sort groups according the selected field.
281
329
  :param sort_dir: Sort direction.
330
+ :param deep: whether to get full stats for each group (will be much slower).
331
+ :param typed: whether to return a typed object (default is self.typed).
282
332
  :return: an iterator over the groups returned by the API.
283
333
  """
284
334
  url = f"{self.base_url}/groups/"
285
- params = {"name": name, "creator": creator, "group": group, "created-before": rfc3339(created_before),
286
- "created-after": rfc3339(created_after), "sort-by": sort_by, "sort-dir": sort_dir}
287
-
288
- left = count
289
- while left > 0:
290
- request_count = min(left, 200)
291
- params["count"] = request_count
292
- d = self._request(url, "GET", params=params).json()
293
-
294
- batch = d["list"][:request_count]
295
- yield from batch
296
-
297
- if "next" not in d:
298
- break
299
-
300
- next_url = d["next"]
301
- left -= len(batch)
302
- params["after"] = parse_qs(urlparse(next_url).query)["after"][0]
303
-
304
- def create_group(self,
305
- name: str,
306
- player_identification: AnyPlayerIdentification,
307
- team_identification: AnyTeamIdentification,
308
- parent: Optional[str] = None
309
- ) -> dict:
335
+ params = {"name": name, "creator": creator, "group": group, "created-before": to_rfc3339(created_before),
336
+ "created-after": to_rfc3339(created_after), "count": count, "sort-by": sort_by, "sort-dir": sort_dir}
337
+ iterator = self._iterable_from_request(url, params)
338
+ if typed is None:
339
+ typed = self.typed
340
+ if deep:
341
+ iterator = (self.get_group(g["id"], typed=typed) for g in iterator)
342
+ elif typed:
343
+ iterator = (ShallowGroup(**g) for g in iterator)
344
+ yield from iterator
345
+
346
+ def create_group(
347
+ self,
348
+ *,
349
+ name: str,
350
+ player_identification: AnyPlayerIdentification,
351
+ team_identification: AnyTeamIdentification,
352
+ parent: Optional[str] = None
353
+ ) -> dict:
310
354
  """
311
355
  Use this API to create a new replay group.
312
356
 
@@ -326,14 +370,25 @@ class BallchasingApi:
326
370
  "team_identification": team_identification, "parent": parent}
327
371
  return self._request(f"/groups", "POST", json=json).json()
328
372
 
329
- def get_group(self, group_id: str) -> dict:
373
+ def get_group(
374
+ self,
375
+ group_id: str,
376
+ *,
377
+ typed: Optional[bool] = None
378
+ ) -> Union[dict, DeepGroup]:
330
379
  """
331
380
  This endpoint retrieves a specific replay group info and stats given its id.
332
381
 
333
382
  :param group_id: the group id.
383
+ :param typed: whether to return a typed object (default is self.typed).
334
384
  :return: the group info with stats.
335
385
  """
336
- return self._request(f"/groups/{group_id}", "GET").json()
386
+ result = self._request(f"/groups/{group_id}", "GET").json()
387
+ if typed is None:
388
+ typed = self.typed
389
+ if typed:
390
+ result = DeepGroup(**result)
391
+ return result
337
392
 
338
393
  def patch_group(self, group_id: str, **params) -> None:
339
394
  """
@@ -353,46 +408,79 @@ class BallchasingApi:
353
408
  """
354
409
  self._request(f"/groups/{group_id}", "DELETE")
355
410
 
356
- def get_group_replays(self, group_id: str, deep: bool = False) -> Iterator[dict]:
411
+ def get_group_replays(
412
+ self,
413
+ group: Union[str, dict, BasicGroup],
414
+ *,
415
+ deep: bool = False,
416
+ typed: Optional[bool] = None
417
+ ) -> Iterator[Union[dict, ShallowReplay, DeepReplay]]:
357
418
  """
358
419
  Finds all replays in a group, including child groups.
359
420
 
360
- :param group_id: the base group id.
421
+ :param group: the base group id, group dict, or BaseGroup object.
361
422
  :param deep: whether or not to get full stats for each replay (will be much slower).
423
+ :param typed: whether to return a typed object (default is self.typed).
362
424
  :return: an iterator over all the replays in the group.
363
425
  """
364
- child_groups = self.get_groups(group=group_id)
365
- for child in child_groups:
366
- for replay in self.get_group_replays(child["id"], deep):
367
- yield replay
368
- for replay in self.get_replays(group_id=group_id, deep=deep):
426
+ for path in self.get_group_tree(group, deep=deep, typed=typed):
427
+ group, replay = path
369
428
  yield replay
370
429
 
371
- def download_replay(self, replay_id: str, folder: str):
430
+ def get_group_tree(
431
+ self,
432
+ group: Union[str, dict, BaseGroup],
433
+ *,
434
+ deep: bool = False,
435
+ typed: Optional[bool] = None
436
+ ):
437
+ """
438
+ Finds all replays in a group, and includes the groups leading up to the replays.
439
+ :param group: the group id or a group dict.
440
+ :param deep: whether to get full stats for each replay and group (will be much slower).
441
+ :param typed: whether to return a typed object (default is self.typed).
442
+ """
443
+ if isinstance(group, str):
444
+ group = self.get_group(group)
445
+ if isinstance(group, BasicGroup):
446
+ group_id = group.id
447
+ else:
448
+ group_id = group["id"]
449
+ child_groups = self.get_groups(group=group_id, typed=typed)
450
+ for child in child_groups:
451
+ for path in self.get_group_tree(child, deep=deep, typed=typed):
452
+ yield group_id, *path
453
+ for replay in self.get_replays(group_id=group_id, deep=deep, typed=typed):
454
+ yield group_id, replay
455
+
456
+ def download_replay(self, replay_id: str, path: str):
372
457
  """
373
458
  Download a replay file.
374
459
 
375
460
  :param replay_id: the replay id.
376
- :param folder: the folder to download into.
461
+ :param path: the path to download the replay to. Can be a file path or a directory.
377
462
  """
378
463
  r = self._request(f"/replays/{replay_id}/file", "GET")
379
- with open(f"{folder}/{replay_id}.replay", "wb") as f:
380
- for ch in r:
381
- f.write(ch)
464
+ if os.path.isdir(path):
465
+ # If path is a directory, use the replay id as the filename
466
+ filename = f"{replay_id}.replay"
467
+ path = os.path.join(path, filename)
468
+ with open(path, "wb") as f:
469
+ f.write(r.content)
382
470
 
383
- def download_group(self, group_id: str, folder: str, recursive=True):
471
+ def download_group(self, group_id: str, folder: str, *, keep_tree_structure=True):
384
472
  """
385
473
  Download an entire group.
386
474
 
387
475
  :param group_id: the base group id.
388
476
  :param folder: the folder in which to create the group folder.
389
- :param recursive: whether to create new folders for child groups.
477
+ :param keep_tree_structure: whether to create new folders for child groups.
390
478
  """
391
479
  folder = os.path.join(folder, group_id)
392
- if recursive:
480
+ if keep_tree_structure:
393
481
  os.makedirs(folder, exist_ok=True)
394
482
  for child_group in self.get_groups(group=group_id):
395
- self.download_group(child_group["id"], folder, True)
483
+ self.download_group(child_group["id"], folder, keep_tree_structure=True)
396
484
  for replay in self.get_replays(group_id=group_id):
397
485
  self.download_replay(replay["id"], folder)
398
486
  else:
@@ -415,9 +503,9 @@ class BallchasingApi:
415
503
  """
416
504
 
417
505
  if isinstance(replay, str):
418
- replay = self.get_replay(replay)
506
+ replay = self.get_replay(replay, typed=False)
419
507
  elif isinstance(replay, dict) and "title" not in replay:
420
- replay = self.get_replay(replay["id"])
508
+ replay = self.get_replay(replay["id"], typed=False)
421
509
 
422
510
  stats = parse_replay_stats(replay)
423
511
  return stats