python-ballchasing 0.4.0__tar.gz → 0.5.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 (30) hide show
  1. {python_ballchasing-0.4.0 → python_ballchasing-0.5.0}/PKG-INFO +6 -5
  2. {python_ballchasing-0.4.0 → python_ballchasing-0.5.0}/README.md +4 -3
  3. {python_ballchasing-0.4.0 → python_ballchasing-0.5.0}/ballchasing/api.py +109 -83
  4. {python_ballchasing-0.4.0 → python_ballchasing-0.5.0}/ballchasing/constants.py +84 -81
  5. {python_ballchasing-0.4.0 → python_ballchasing-0.5.0}/ballchasing/typed/deep_group.py +27 -27
  6. {python_ballchasing-0.4.0 → python_ballchasing-0.5.0}/ballchasing/typed/deep_replay.py +16 -16
  7. {python_ballchasing-0.4.0 → python_ballchasing-0.5.0}/ballchasing/typed/shared.py +33 -8
  8. python_ballchasing-0.5.0/ballchasing/util/__init__.py +4 -0
  9. python_ballchasing-0.5.0/ballchasing/util/dates.py +35 -0
  10. python_ballchasing-0.5.0/ballchasing/util/iterators.py +118 -0
  11. python_ballchasing-0.5.0/ballchasing/util/replays.py +185 -0
  12. python_ballchasing-0.4.0/ballchasing/util.py → python_ballchasing-0.5.0/ballchasing/util/stats.py +7 -30
  13. {python_ballchasing-0.4.0 → python_ballchasing-0.5.0}/pyproject.toml +3 -3
  14. {python_ballchasing-0.4.0 → python_ballchasing-0.5.0}/python_ballchasing.egg-info/PKG-INFO +6 -5
  15. {python_ballchasing-0.4.0 → python_ballchasing-0.5.0}/python_ballchasing.egg-info/SOURCES.txt +6 -1
  16. python_ballchasing-0.5.0/scripts/get_maps.py +147 -0
  17. python_ballchasing-0.5.0/scripts/test_typed.py +88 -0
  18. python_ballchasing-0.4.0/scripts/test_typed.py +0 -35
  19. {python_ballchasing-0.4.0 → python_ballchasing-0.5.0}/LICENSE +0 -0
  20. {python_ballchasing-0.4.0 → python_ballchasing-0.5.0}/ballchasing/__init__.py +0 -0
  21. {python_ballchasing-0.4.0 → python_ballchasing-0.5.0}/ballchasing/stats_info.tsv +0 -0
  22. {python_ballchasing-0.4.0 → python_ballchasing-0.5.0}/ballchasing/typed/__init__.py +0 -0
  23. {python_ballchasing-0.4.0 → python_ballchasing-0.5.0}/ballchasing/typed/shallow_group.py +0 -0
  24. {python_ballchasing-0.4.0 → python_ballchasing-0.5.0}/ballchasing/typed/shallow_replay.py +0 -0
  25. {python_ballchasing-0.4.0 → python_ballchasing-0.5.0}/python_ballchasing.egg-info/dependency_links.txt +0 -0
  26. {python_ballchasing-0.4.0 → python_ballchasing-0.5.0}/python_ballchasing.egg-info/requires.txt +0 -0
  27. {python_ballchasing-0.4.0 → python_ballchasing-0.5.0}/python_ballchasing.egg-info/top_level.txt +0 -0
  28. {python_ballchasing-0.4.0 → python_ballchasing-0.5.0}/scripts/make_types.py +0 -0
  29. {python_ballchasing-0.4.0 → python_ballchasing-0.5.0}/scripts/test.py +0 -0
  30. {python_ballchasing-0.4.0 → python_ballchasing-0.5.0}/setup.cfg +0 -0
@@ -1,15 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-ballchasing
3
- Version: 0.4.0
3
+ Version: 0.5.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.9
9
+ Classifier: Programming Language :: Python :: 3.10
10
10
  Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Operating System :: OS Independent
12
- Requires-Python: >=3.9
12
+ Requires-Python: >=3.10
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
15
  Requires-Dist: requests
@@ -78,7 +78,7 @@ for group in groups:
78
78
  # Download the group
79
79
  api.download_group(
80
80
  group_id=group["id"],
81
- folder="/path/to/destination/"
81
+ path="/path/to/destination/"
82
82
  recursive=True, # To download all the replays and retain the group structure with subfolders
83
83
  )
84
84
 
@@ -88,9 +88,10 @@ for group in groups:
88
88
  deep=True, # To get detailed replay info
89
89
  )
90
90
  for replay in replays:
91
- api.download_replay(replay_id=replay["id"], folder="/path/to/destination/") # You could also download like this
91
+ api.download_replay(replay_id=replay["id"], path="/path/to/destination/") # You could also download like this
92
92
  ```
93
93
 
94
+ ---
94
95
  Additionally, there's the option to put responses (replays, groups) into typed objects for better type hinting and validation.
95
96
  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
97
  The classes can also be found under the `typing` folder and used as a reference for what the API returns.
@@ -61,7 +61,7 @@ for group in groups:
61
61
  # Download the group
62
62
  api.download_group(
63
63
  group_id=group["id"],
64
- folder="/path/to/destination/"
64
+ path="/path/to/destination/"
65
65
  recursive=True, # To download all the replays and retain the group structure with subfolders
66
66
  )
67
67
 
@@ -71,9 +71,10 @@ for group in groups:
71
71
  deep=True, # To get detailed replay info
72
72
  )
73
73
  for replay in replays:
74
- api.download_replay(replay_id=replay["id"], folder="/path/to/destination/") # You could also download like this
74
+ api.download_replay(replay_id=replay["id"], path="/path/to/destination/") # You could also download like this
75
75
  ```
76
76
 
77
+ ---
77
78
  Additionally, there's the option to put responses (replays, groups) into typed objects for better type hinting and validation.
78
79
  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
80
  The classes can also be found under the `typing` folder and used as a reference for what the API returns.
@@ -88,4 +89,4 @@ print(replay.blue.players[0].name) # Example of easy attribute access
88
89
 
89
90
  group = api.get_group("g2-vs-bds-hwbf2eyolb", typed=True)
90
91
  print(group.players[0].name)
91
- ```
92
+ ```
@@ -1,17 +1,20 @@
1
1
  import os
2
2
  import time
3
+ from concurrent.futures import ThreadPoolExecutor
3
4
  from datetime import datetime
4
5
  from pathlib import Path
5
- from typing import Optional, Union, List, BinaryIO, Iterator
6
+ from typing import BinaryIO, Iterator
6
7
  from urllib.parse import parse_qs, urlparse
7
8
 
8
- from requests import sessions, Response, ConnectionError, HTTPError
9
+ from requests import sessions, Response, ConnectionError
9
10
 
10
- from ballchasing.constants import GroupSortBy, SortDir, AnyPlaylist, AnyMap, AnySeason, AnyRank, AnyReplaySortBy, \
11
- AnySortDir, AnyVisibility, AnyGroupSortBy, AnyPlayerIdentification, AnyTeamIdentification, AnyMatchResult
11
+ from ballchasing.constants import GroupSortBy, SortDir, AnySeason, AnyRank, AnyReplaySortBy, AnySortDir, \
12
+ AnyVisibility, AnyGroupSortBy, AnyPlayerIdentification, AnyTeamIdentification, AnyMatchResult, NoneOrMore
12
13
  from ballchasing.typed import DeepReplay, ShallowReplay, DeepGroup, ShallowGroup
13
14
  from .typed.shared import BaseGroup, BasicGroup
14
- from .util import to_rfc3339, parse_replay_stats
15
+ from .util.dates import to_rfc3339
16
+ from .util.iterators import deduplicate as deduplicator
17
+ from .util.stats import parse_replay_stats
15
18
 
16
19
  DEFAULT_URL = "https://ballchasing.com/api"
17
20
 
@@ -25,11 +28,11 @@ class BallchasingApi:
25
28
  self,
26
29
  auth_key: str,
27
30
  *,
28
- sleep_time_on_rate_limit: Optional[float] = None,
31
+ sleep_time_on_rate_limit: float | None = None,
29
32
  print_on_rate_limit: bool = False,
30
- base_url=None,
31
- do_initial_ping=True,
32
- typed=False,
33
+ base_url: str | None = None,
34
+ do_initial_ping: bool = True,
35
+ typed: bool = False,
33
36
  ):
34
37
  """
35
38
 
@@ -117,6 +120,8 @@ class BallchasingApi:
117
120
  time.sleep(retry_after)
118
121
  elif self.sleep_time_on_rate_limit:
119
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.")
120
125
  else:
121
126
  r.raise_for_status() # Raise an error for any other status code'
122
127
  except ConnectionError as e:
@@ -141,56 +146,68 @@ class BallchasingApi:
141
146
  self._ping_result = result
142
147
  return result
143
148
 
144
- def _iterable_from_request(self, url, params):
145
- # Shared by get_replays and get_groups
149
+ def _iterable_from_request(self, url, params, prefetch=True):
150
+ # Shared by get_replays and get_groups.
151
+ # When prefetch=True, the next page is requested in a background
152
+ # thread *before* yielding, so network I/O overlaps with the
153
+ # consumer processing items. When prefetch=False (e.g. deep mode
154
+ # where the consumer also makes API calls), the next request is
155
+ # submitted *after* yielding to avoid doubling the request rate.
146
156
  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
157
 
159
- batch = d["list"][:request_count]
160
- yield from batch
158
+ def fetch_page(p):
159
+ return self._request(url, "GET", params=p).json()
160
+
161
+ with ThreadPoolExecutor(max_workers=1) as executor:
162
+ params["count"] = min(remaining, 200)
163
+ future = executor.submit(fetch_page, dict(params))
164
+
165
+ while remaining > 0:
166
+ d = future.result()
167
+ batch = d["list"][:min(remaining, 200)]
168
+ remaining -= len(batch)
169
+
170
+ has_next = "next" in d and remaining > 0
171
+ if has_next:
172
+ next_url = d["next"]
173
+ params["after"] = parse_qs(urlparse(next_url).query)["after"][0]
174
+ params["count"] = min(remaining, 200)
175
+ if prefetch:
176
+ future = executor.submit(fetch_page, dict(params))
161
177
 
162
- if "next" not in d:
163
- break
178
+ yield from batch
164
179
 
165
- next_url = d["next"]
166
- remaining -= len(batch)
167
- params["after"] = parse_qs(urlparse(next_url).query)["after"][0]
180
+ if not has_next:
181
+ break
182
+ if not prefetch:
183
+ future = executor.submit(fetch_page, dict(params))
168
184
 
169
185
  def get_replays(
170
186
  self,
171
187
  *,
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
+ title: NoneOrMore[str] = None,
189
+ player_name: NoneOrMore[str] = None,
190
+ player_id: NoneOrMore[str] = None,
191
+ playlist: NoneOrMore[str] = None,
192
+ season: NoneOrMore[AnySeason] = None,
193
+ match_result: NoneOrMore[AnyMatchResult] = None,
194
+ min_rank: AnyRank | None = None,
195
+ max_rank: AnyRank | None = None,
196
+ pro: bool | None = None,
197
+ uploader: str | None = None,
198
+ group_id: NoneOrMore[str] = None,
199
+ map_id: NoneOrMore[str] = None,
200
+ created_before: str | datetime | None = None,
201
+ created_after: str | datetime | None = None,
202
+ replay_after: str | datetime | None = None,
203
+ replay_before: str | datetime | None = None,
188
204
  count: int = 150,
189
- sort_by: Optional[AnyReplaySortBy] = None,
205
+ sort_by: AnyReplaySortBy | None = None,
190
206
  sort_dir: AnySortDir = SortDir.DESCENDING,
191
207
  deep: bool = False,
192
- typed: Optional[bool] = None,
193
- ) -> Iterator[Union[dict, ShallowReplay, DeepReplay]]:
208
+ typed: bool | None = None,
209
+ deduplicate: bool = False,
210
+ ) -> Iterator[dict | ShallowReplay | DeepReplay]:
194
211
  """
195
212
  This endpoint lets you filter and retrieve replays. The implementation returns an iterator.
196
213
 
@@ -224,12 +241,14 @@ class BallchasingApi:
224
241
  :param sort_dir: sort direction
225
242
  :param deep: whether to get full stats for each replay (will be much slower).
226
243
  :param typed: whether to return a typed object (default is self.typed).
244
+ :param deduplicate: whether to deduplicate replays that seem to be the same game.
227
245
  :return: an iterator over the replays returned by the API.
228
246
  """
229
247
  url = f"{self.base_url}/replays"
230
248
  params = {"title": title, "player-name": player_name, "player-id": player_id, "playlist": playlist,
231
249
  "season": season, "match-result": match_result, "min-rank": min_rank, "max-rank": max_rank,
232
- "pro": pro, "uploader": uploader, "group": group_id, "map": map_id,
250
+ "pro": str(pro).lower() if isinstance(pro, bool) else pro, "uploader": uploader, "group": group_id,
251
+ "map": map_id,
233
252
  "created-before": to_rfc3339(created_before), "created-after": to_rfc3339(created_after),
234
253
  "replay-date-after": to_rfc3339(replay_after), "replay-date-before": to_rfc3339(replay_before),
235
254
  "count": count, "sort-by": sort_by, "sort-dir": sort_dir}
@@ -237,14 +256,20 @@ class BallchasingApi:
237
256
  if typed is None:
238
257
  typed = self.typed
239
258
 
240
- iterator = self._iterable_from_request(url, params)
259
+ iterator = self._iterable_from_request(url, params, prefetch=not deep)
241
260
  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)
261
+ iterator = (self.get_replay(r["id"]) for r in iterator)
262
+ if deduplicate:
263
+ # Deep replays have match and replay IDs to deduplicate with. For shallow replays we check dates.
264
+ iterator = deduplicator(iterator, check_dates=not deep)
265
+ if typed:
266
+ if deep:
267
+ iterator = (DeepReplay(**r) for r in iterator)
268
+ else:
269
+ iterator = (ShallowReplay(**r) for r in iterator)
245
270
  yield from iterator
246
271
 
247
- def get_replay(self, replay_id: str, *, typed: Optional[bool] = None) -> Union[dict, DeepReplay]:
272
+ def get_replay(self, replay_id: str, *, typed: bool | None = None) -> dict | DeepReplay:
248
273
  """
249
274
  Retrieve a given replay’s details and stats.
250
275
 
@@ -270,10 +295,10 @@ class BallchasingApi:
270
295
 
271
296
  def upload_replay(
272
297
  self,
273
- replay_file: Union[str, Path, BinaryIO],
298
+ replay_file: str | Path | BinaryIO,
274
299
  *,
275
- visibility: Optional[AnyVisibility] = None,
276
- group: Optional[str] = None
300
+ visibility: AnyVisibility | None = None,
301
+ group: str | None = None
277
302
  ) -> dict:
278
303
  """
279
304
  Use this API to upload a replay file to ballchasing.com.
@@ -301,17 +326,17 @@ class BallchasingApi:
301
326
  def get_groups(
302
327
  self,
303
328
  *,
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,
329
+ name: str | None = None,
330
+ creator: str | None = None,
331
+ group: str | None = None,
332
+ created_before: str | datetime | None = None,
333
+ created_after: str | datetime | None = None,
309
334
  count: int = 200,
310
335
  sort_by: AnyGroupSortBy = GroupSortBy.CREATED,
311
336
  sort_dir: AnySortDir = SortDir.DESCENDING,
312
337
  deep: bool = False,
313
- typed: bool = None,
314
- ) -> Iterator[Union[dict, ShallowGroup, DeepGroup]]:
338
+ typed: bool | None = None,
339
+ ) -> Iterator[dict | ShallowGroup | DeepGroup]:
315
340
  """
316
341
  This endpoint lets you filter and retrieve replay groups.
317
342
 
@@ -334,7 +359,7 @@ class BallchasingApi:
334
359
  url = f"{self.base_url}/groups/"
335
360
  params = {"name": name, "creator": creator, "group": group, "created-before": to_rfc3339(created_before),
336
361
  "created-after": to_rfc3339(created_after), "count": count, "sort-by": sort_by, "sort-dir": sort_dir}
337
- iterator = self._iterable_from_request(url, params)
362
+ iterator = self._iterable_from_request(url, params, prefetch=not deep)
338
363
  if typed is None:
339
364
  typed = self.typed
340
365
  if deep:
@@ -349,7 +374,7 @@ class BallchasingApi:
349
374
  name: str,
350
375
  player_identification: AnyPlayerIdentification,
351
376
  team_identification: AnyTeamIdentification,
352
- parent: Optional[str] = None
377
+ parent: str | None = None
353
378
  ) -> dict:
354
379
  """
355
380
  Use this API to create a new replay group.
@@ -374,8 +399,8 @@ class BallchasingApi:
374
399
  self,
375
400
  group_id: str,
376
401
  *,
377
- typed: Optional[bool] = None
378
- ) -> Union[dict, DeepGroup]:
402
+ typed: bool | None = None
403
+ ) -> dict | DeepGroup:
379
404
  """
380
405
  This endpoint retrieves a specific replay group info and stats given its id.
381
406
 
@@ -410,11 +435,11 @@ class BallchasingApi:
410
435
 
411
436
  def get_group_replays(
412
437
  self,
413
- group: Union[str, dict, BasicGroup],
438
+ group: str | dict | BasicGroup,
414
439
  *,
415
440
  deep: bool = False,
416
- typed: Optional[bool] = None
417
- ) -> Iterator[Union[dict, ShallowReplay, DeepReplay]]:
441
+ typed: bool | None = None
442
+ ) -> Iterator[dict | ShallowReplay | DeepReplay]:
418
443
  """
419
444
  Finds all replays in a group, including child groups.
420
445
 
@@ -423,35 +448,36 @@ class BallchasingApi:
423
448
  :param typed: whether to return a typed object (default is self.typed).
424
449
  :return: an iterator over all the replays in the group.
425
450
  """
426
- for path in self.get_group_tree(group, deep=deep, typed=typed):
427
- group, replay = path
451
+ for path, replay in self.get_group_tree(group, deep=deep, typed=typed):
428
452
  yield replay
429
453
 
430
454
  def get_group_tree(
431
455
  self,
432
- group: Union[str, dict, BaseGroup],
456
+ group: str | dict | BaseGroup,
433
457
  *,
434
458
  deep: bool = False,
435
- typed: Optional[bool] = None
436
- ):
459
+ typed: bool | None = None
460
+ ) -> Iterator[tuple[list[str], dict | ShallowReplay | DeepReplay]]:
437
461
  """
438
- Finds all replays in a group, and includes the groups leading up to the replays.
462
+ Finds all replays in a group, and includes the group path leading up to each replay.
463
+
439
464
  :param group: the group id or a group dict.
440
465
  :param deep: whether to get full stats for each replay and group (will be much slower).
441
466
  :param typed: whether to return a typed object (default is self.typed).
467
+ :return: an iterator of (path, replay) tuples, where path is a list of group ids.
442
468
  """
443
469
  if isinstance(group, str):
444
- group = self.get_group(group)
445
- if isinstance(group, BasicGroup):
470
+ group_id = group
471
+ elif isinstance(group, BasicGroup):
446
472
  group_id = group.id
447
473
  else:
448
474
  group_id = group["id"]
449
475
  child_groups = self.get_groups(group=group_id, typed=typed)
450
476
  for child in child_groups:
451
- for path in self.get_group_tree(child, deep=deep, typed=typed):
452
- yield group_id, *path
477
+ for path, replay in self.get_group_tree(child, deep=deep, typed=typed):
478
+ yield [group_id] + path, replay
453
479
  for replay in self.get_replays(group_id=group_id, deep=deep, typed=typed):
454
- yield group_id, replay
480
+ yield [group_id], replay
455
481
 
456
482
  def download_replay(self, replay_id: str, path: str):
457
483
  """
@@ -494,7 +520,7 @@ class BallchasingApi:
494
520
  res = self._request("/maps", "GET").json()
495
521
  return res
496
522
 
497
- def get_stats(self, replay: Union[dict, str]):
523
+ def get_stats(self, replay: dict | str):
498
524
  """
499
525
  Gets stats for players, teams and replay info.
500
526