python-ballchasing 0.3.0__tar.gz → 0.4.0__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 (24) hide show
  1. {python_ballchasing-0.3.0 → python_ballchasing-0.4.0}/PKG-INFO +18 -2
  2. {python_ballchasing-0.3.0 → python_ballchasing-0.4.0}/README.md +16 -0
  3. {python_ballchasing-0.3.0 → python_ballchasing-0.4.0}/ballchasing/__init__.py +7 -0
  4. {python_ballchasing-0.3.0 → python_ballchasing-0.4.0}/ballchasing/api.py +142 -68
  5. python_ballchasing-0.4.0/ballchasing/typed/__init__.py +5 -0
  6. python_ballchasing-0.4.0/ballchasing/typed/deep_group.py +440 -0
  7. python_ballchasing-0.4.0/ballchasing/typed/deep_replay.py +286 -0
  8. python_ballchasing-0.4.0/ballchasing/typed/shallow_group.py +18 -0
  9. python_ballchasing-0.4.0/ballchasing/typed/shallow_replay.py +67 -0
  10. python_ballchasing-0.4.0/ballchasing/typed/shared.py +218 -0
  11. {python_ballchasing-0.3.0 → python_ballchasing-0.4.0}/ballchasing/util.py +16 -4
  12. {python_ballchasing-0.3.0 → python_ballchasing-0.4.0}/pyproject.toml +2 -2
  13. {python_ballchasing-0.3.0 → python_ballchasing-0.4.0}/python_ballchasing.egg-info/PKG-INFO +18 -2
  14. {python_ballchasing-0.3.0 → python_ballchasing-0.4.0}/python_ballchasing.egg-info/SOURCES.txt +10 -1
  15. {python_ballchasing-0.3.0 → python_ballchasing-0.4.0}/python_ballchasing.egg-info/top_level.txt +1 -0
  16. python_ballchasing-0.4.0/scripts/make_types.py +194 -0
  17. python_ballchasing-0.4.0/scripts/test.py +52 -0
  18. python_ballchasing-0.4.0/scripts/test_typed.py +35 -0
  19. {python_ballchasing-0.3.0 → python_ballchasing-0.4.0}/LICENSE +0 -0
  20. {python_ballchasing-0.3.0 → python_ballchasing-0.4.0}/ballchasing/constants.py +0 -0
  21. {python_ballchasing-0.3.0 → python_ballchasing-0.4.0}/ballchasing/stats_info.tsv +0 -0
  22. {python_ballchasing-0.3.0 → python_ballchasing-0.4.0}/python_ballchasing.egg-info/dependency_links.txt +0 -0
  23. {python_ballchasing-0.3.0 → python_ballchasing-0.4.0}/python_ballchasing.egg-info/requires.txt +0 -0
  24. {python_ballchasing-0.3.0 → python_ballchasing-0.4.0}/setup.cfg +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-ballchasing
3
- Version: 0.3.0
3
+ Version: 0.4.0
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
7
7
  Project-URL: Homepage, https://github.com/Rolv-Arild/python-ballchasing
8
8
  Project-URL: Download, https://pypi.python.org/pypi/python-ballchasing
9
- Classifier: Programming Language :: Python :: 3.8
9
+ Classifier: Programming Language :: Python :: 3.9
10
10
  Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Operating System :: OS Independent
12
12
  Requires-Python: >=3.9
@@ -90,3 +90,19 @@ for group in groups:
90
90
  for replay in replays:
91
91
  api.download_replay(replay_id=replay["id"], folder="/path/to/destination/") # You could also download like this
92
92
  ```
93
+
94
+ Additionally, there's the option to put responses (replays, groups) into typed objects for better type hinting and validation.
95
+ It also attempts to fill in missing variables not returned by ballchasing (e.g. if a team has 0 goals they won't have a "goal" entry in the response)
96
+ The classes can also be found under the `typing` folder and used as a reference for what the API returns.
97
+
98
+ ```python
99
+ # When creating the API, you can specify a default setting for typing (defaults to False)
100
+ api = ballchasing.Api("Your token here", typed=True)
101
+
102
+ # or you can specify it explicitly
103
+ replay = api.get_replay("2627e02a-aa46-4e13-b66b-b76a32069a07", typed=True)
104
+ print(replay.blue.players[0].name) # Example of easy attribute access
105
+
106
+ group = api.get_group("g2-vs-bds-hwbf2eyolb", typed=True)
107
+ print(group.players[0].name)
108
+ ```
@@ -73,3 +73,19 @@ for group in groups:
73
73
  for replay in replays:
74
74
  api.download_replay(replay_id=replay["id"], folder="/path/to/destination/") # You could also download like this
75
75
  ```
76
+
77
+ Additionally, there's the option to put responses (replays, groups) into typed objects for better type hinting and validation.
78
+ It also attempts to fill in missing variables not returned by ballchasing (e.g. if a team has 0 goals they won't have a "goal" entry in the response)
79
+ The classes can also be found under the `typing` folder and used as a reference for what the API returns.
80
+
81
+ ```python
82
+ # When creating the API, you can specify a default setting for typing (defaults to False)
83
+ api = ballchasing.Api("Your token here", typed=True)
84
+
85
+ # or you can specify it explicitly
86
+ replay = api.get_replay("2627e02a-aa46-4e13-b66b-b76a32069a07", typed=True)
87
+ print(replay.blue.players[0].name) # Example of easy attribute access
88
+
89
+ group = api.get_group("g2-vs-bds-hwbf2eyolb", typed=True)
90
+ print(group.players[0].name)
91
+ ```
@@ -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
+ )
@@ -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,34 @@ 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
+
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
+
139
169
  def get_replays(
140
170
  self,
171
+ *,
141
172
  title: Optional[str] = None,
142
173
  player_name: Optional[Union[str, List[str]]] = None,
143
174
  player_id: Optional[Union[str, List[str]]] = None,
@@ -157,8 +188,9 @@ class BallchasingApi:
157
188
  count: int = 150,
158
189
  sort_by: Optional[AnyReplaySortBy] = None,
159
190
  sort_dir: AnySortDir = SortDir.DESCENDING,
160
- deep: bool = False
161
- ) -> Iterator[dict]:
191
+ deep: bool = False,
192
+ typed: Optional[bool] = None,
193
+ ) -> Iterator[Union[dict, ShallowReplay, DeepReplay]]:
162
194
  """
163
195
  This endpoint lets you filter and retrieve replays. The implementation returns an iterator.
164
196
 
@@ -191,42 +223,41 @@ class BallchasingApi:
191
223
  :param sort_by: sort replays according the selected field
192
224
  :param sort_dir: sort direction
193
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).
194
227
  :return: an iterator over the replays returned by the API.
195
228
  """
196
229
  url = f"{self.base_url}/replays"
197
230
  params = {"title": title, "player-name": player_name, "player-id": player_id, "playlist": playlist,
198
231
  "season": season, "match-result": match_result, "min-rank": min_rank, "max-rank": max_rank,
199
232
  "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()
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}
208
236
 
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)
237
+ if typed is None:
238
+ typed = self.typed
214
239
 
215
- if "next" not in d:
216
- break
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
217
246
 
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:
247
+ def get_replay(self, replay_id: str, *, typed: Optional[bool] = None) -> Union[dict, DeepReplay]:
223
248
  """
224
249
  Retrieve a given replay’s details and stats.
225
250
 
226
251
  :param replay_id: the replay id.
252
+ :param typed: whether to return a typed object (default is self.typed).
227
253
  :return: the result of the GET request.
228
254
  """
229
- 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
230
261
 
231
262
  def patch_replay(self, replay_id: str, **params) -> None:
232
263
  """
@@ -240,6 +271,7 @@ class BallchasingApi:
240
271
  def upload_replay(
241
272
  self,
242
273
  replay_file: Union[str, Path, BinaryIO],
274
+ *,
243
275
  visibility: Optional[AnyVisibility] = None,
244
276
  group: Optional[str] = None
245
277
  ) -> dict:
@@ -253,7 +285,7 @@ class BallchasingApi:
253
285
  """
254
286
  if isinstance(replay_file, (str, Path)):
255
287
  with open(replay_file, "rb") as f:
256
- return self.upload_replay(f, visibility, group)
288
+ return self.upload_replay(f, visibility=visibility, group=group)
257
289
  return self._request(f"/v2/upload", "POST", files={"file": replay_file},
258
290
  params={"group": group, "visibility": visibility}).json()
259
291
 
@@ -268,6 +300,7 @@ class BallchasingApi:
268
300
 
269
301
  def get_groups(
270
302
  self,
303
+ *,
271
304
  name: Optional[str] = None,
272
305
  creator: Optional[str] = None,
273
306
  group: Optional[str] = None,
@@ -275,8 +308,10 @@ class BallchasingApi:
275
308
  created_after: Optional[Union[str, datetime]] = None,
276
309
  count: int = 200,
277
310
  sort_by: AnyGroupSortBy = GroupSortBy.CREATED,
278
- sort_dir: AnySortDir = SortDir.DESCENDING
279
- ) -> Iterator[dict]:
311
+ sort_dir: AnySortDir = SortDir.DESCENDING,
312
+ deep: bool = False,
313
+ typed: bool = None,
314
+ ) -> Iterator[Union[dict, ShallowGroup, DeepGroup]]:
280
315
  """
281
316
  This endpoint lets you filter and retrieve replay groups.
282
317
 
@@ -292,30 +327,25 @@ class BallchasingApi:
292
327
  past the limit of 200 set by the API
293
328
  :param sort_by: Sort groups according the selected field.
294
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).
295
332
  :return: an iterator over the groups returned by the API.
296
333
  """
297
334
  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]
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
316
345
 
317
346
  def create_group(
318
347
  self,
348
+ *,
319
349
  name: str,
320
350
  player_identification: AnyPlayerIdentification,
321
351
  team_identification: AnyTeamIdentification,
@@ -340,14 +370,25 @@ class BallchasingApi:
340
370
  "team_identification": team_identification, "parent": parent}
341
371
  return self._request(f"/groups", "POST", json=json).json()
342
372
 
343
- 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]:
344
379
  """
345
380
  This endpoint retrieves a specific replay group info and stats given its id.
346
381
 
347
382
  :param group_id: the group id.
383
+ :param typed: whether to return a typed object (default is self.typed).
348
384
  :return: the group info with stats.
349
385
  """
350
- 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
351
392
 
352
393
  def patch_group(self, group_id: str, **params) -> None:
353
394
  """
@@ -367,46 +408,79 @@ class BallchasingApi:
367
408
  """
368
409
  self._request(f"/groups/{group_id}", "DELETE")
369
410
 
370
- 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]]:
371
418
  """
372
419
  Finds all replays in a group, including child groups.
373
420
 
374
- :param group_id: the base group id.
421
+ :param group: the base group id, group dict, or BaseGroup object.
375
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).
376
424
  :return: an iterator over all the replays in the group.
377
425
  """
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):
426
+ for path in self.get_group_tree(group, deep=deep, typed=typed):
427
+ group, replay = path
383
428
  yield replay
384
429
 
385
- 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):
386
457
  """
387
458
  Download a replay file.
388
459
 
389
460
  :param replay_id: the replay id.
390
- :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.
391
462
  """
392
463
  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)
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)
396
470
 
397
- 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):
398
472
  """
399
473
  Download an entire group.
400
474
 
401
475
  :param group_id: the base group id.
402
476
  :param folder: the folder in which to create the group folder.
403
- :param recursive: whether to create new folders for child groups.
477
+ :param keep_tree_structure: whether to create new folders for child groups.
404
478
  """
405
479
  folder = os.path.join(folder, group_id)
406
- if recursive:
480
+ if keep_tree_structure:
407
481
  os.makedirs(folder, exist_ok=True)
408
482
  for child_group in self.get_groups(group=group_id):
409
- self.download_group(child_group["id"], folder, True)
483
+ self.download_group(child_group["id"], folder, keep_tree_structure=True)
410
484
  for replay in self.get_replays(group_id=group_id):
411
485
  self.download_replay(replay["id"], folder)
412
486
  else:
@@ -429,9 +503,9 @@ class BallchasingApi:
429
503
  """
430
504
 
431
505
  if isinstance(replay, str):
432
- replay = self.get_replay(replay)
506
+ replay = self.get_replay(replay, typed=False)
433
507
  elif isinstance(replay, dict) and "title" not in replay:
434
- replay = self.get_replay(replay["id"])
508
+ replay = self.get_replay(replay["id"], typed=False)
435
509
 
436
510
  stats = parse_replay_stats(replay)
437
511
  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