python-ballchasing 0.5.0__tar.gz → 0.5.2__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.5.0 → python_ballchasing-0.5.2}/PKG-INFO +1 -1
  2. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/ballchasing/api.py +67 -27
  3. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/ballchasing/typed/shared.py +6 -5
  4. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/ballchasing/util/replays.py +2 -2
  5. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/pyproject.toml +2 -2
  6. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/python_ballchasing.egg-info/PKG-INFO +1 -1
  7. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/python_ballchasing.egg-info/SOURCES.txt +2 -1
  8. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/python_ballchasing.egg-info/top_level.txt +1 -0
  9. python_ballchasing-0.5.2/tests/test_fixes.py +246 -0
  10. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/LICENSE +0 -0
  11. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/README.md +0 -0
  12. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/ballchasing/__init__.py +0 -0
  13. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/ballchasing/constants.py +0 -0
  14. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/ballchasing/stats_info.tsv +0 -0
  15. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/ballchasing/typed/__init__.py +0 -0
  16. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/ballchasing/typed/deep_group.py +0 -0
  17. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/ballchasing/typed/deep_replay.py +0 -0
  18. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/ballchasing/typed/shallow_group.py +0 -0
  19. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/ballchasing/typed/shallow_replay.py +0 -0
  20. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/ballchasing/util/__init__.py +0 -0
  21. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/ballchasing/util/dates.py +0 -0
  22. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/ballchasing/util/iterators.py +0 -0
  23. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/ballchasing/util/stats.py +0 -0
  24. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/python_ballchasing.egg-info/dependency_links.txt +0 -0
  25. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/python_ballchasing.egg-info/requires.txt +0 -0
  26. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/scripts/get_maps.py +0 -0
  27. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/scripts/make_types.py +0 -0
  28. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/scripts/test.py +0 -0
  29. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/scripts/test_typed.py +0 -0
  30. {python_ballchasing-0.5.0 → python_ballchasing-0.5.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-ballchasing
3
- Version: 0.5.0
3
+ Version: 0.5.2
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
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  import os
2
3
  import time
3
4
  from concurrent.futures import ThreadPoolExecutor
@@ -6,7 +7,8 @@ from pathlib import Path
6
7
  from typing import BinaryIO, Iterator
7
8
  from urllib.parse import parse_qs, urlparse
8
9
 
9
- from requests import sessions, Response, ConnectionError
10
+ from requests import sessions, Response
11
+ from requests.exceptions import ConnectionError, Timeout
10
12
 
11
13
  from ballchasing.constants import GroupSortBy, SortDir, AnySeason, AnyRank, AnyReplaySortBy, AnySortDir, \
12
14
  AnyVisibility, AnyGroupSortBy, AnyPlayerIdentification, AnyTeamIdentification, AnyMatchResult, NoneOrMore
@@ -16,8 +18,18 @@ from .util.dates import to_rfc3339
16
18
  from .util.iterators import deduplicate as deduplicator
17
19
  from .util.stats import parse_replay_stats
18
20
 
21
+ logger = logging.getLogger("ballchasing")
22
+
19
23
  DEFAULT_URL = "https://ballchasing.com/api"
20
24
 
25
+ PATRON_RATE_LIMIT_SLEEP_TIMES = {
26
+ "regular": 3600 / 1000,
27
+ "gold": 3600 / 2000,
28
+ "diamond": 3600 / 5000,
29
+ "champion": 1 / 8,
30
+ "gc": 1 / 16,
31
+ }
32
+
21
33
 
22
34
  class BallchasingApi:
23
35
  """
@@ -48,19 +60,22 @@ class BallchasingApi:
48
60
  self.base_url = DEFAULT_URL if base_url is None else base_url
49
61
  if do_initial_ping:
50
62
  self.ping()
51
- if sleep_time_on_rate_limit is None:
52
- self.sleep_time_on_rate_limit = {
53
- "regular": 3600 / 1000,
54
- "gold": 3600 / 2000,
55
- "diamond": 3600 / 5000,
56
- "champion": 1 / 8,
57
- "gc": 1 / 16
58
- }.get(self.patron_type or "regular")
59
- else:
60
- self.sleep_time_on_rate_limit = sleep_time_on_rate_limit
63
+ self._sleep_time_on_rate_limit = sleep_time_on_rate_limit
61
64
  self.print_on_rate_limit = print_on_rate_limit
62
65
  self.typed = typed
63
66
 
67
+ @property
68
+ def sleep_time_on_rate_limit(self) -> float:
69
+ if self._sleep_time_on_rate_limit is not None:
70
+ return self._sleep_time_on_rate_limit
71
+ if self._ping_result is not None:
72
+ return PATRON_RATE_LIMIT_SLEEP_TIMES.get(self.patron_type or "regular", 3600 / 1000)
73
+ return PATRON_RATE_LIMIT_SLEEP_TIMES["regular"]
74
+
75
+ @sleep_time_on_rate_limit.setter
76
+ def sleep_time_on_rate_limit(self, value: float | None):
77
+ self._sleep_time_on_rate_limit = value
78
+
64
79
  @property
65
80
  def steam_name(self):
66
81
  if self._ping_result is None:
@@ -105,6 +120,7 @@ class BallchasingApi:
105
120
  url = f"{self.base_url}{url_or_endpoint}" if url_or_endpoint.startswith("/") else url_or_endpoint
106
121
  max_retries = 8
107
122
  retries = 0
123
+ rate_limit_retries = 0
108
124
  while True:
109
125
  try:
110
126
  r: Response = self._session.request(method=method, url=url, headers=headers, **params)
@@ -113,22 +129,29 @@ class BallchasingApi:
113
129
  elif r.status_code == 429:
114
130
  self.rate_limit_count += 1
115
131
  if self.print_on_rate_limit:
116
- print(f"Rate limited at {url} ({self.rate_limit_count} total rate limits)")
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
+ rate_limit_retries += 1
136
+ if rate_limit_retries > max_retries:
137
+ r.raise_for_status()
117
138
  retry_after = r.headers.get("Retry-After", '0')
118
139
  retry_after = int(retry_after) if retry_after.isdigit() else None
119
140
  if retry_after: # integer > 0
120
141
  time.sleep(retry_after)
121
142
  elif self.sleep_time_on_rate_limit:
122
143
  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.")
144
+ else:
145
+ time.sleep(max(1.0, float(2 ** (rate_limit_retries - 1))))
146
+ elif r.status_code in (502, 503, 504):
147
+ raise ConnectionError(f"Server error {r.status_code}: {r.reason or 'Transient gateway error'}")
125
148
  else:
126
- r.raise_for_status() # Raise an error for any other status code'
127
- except ConnectionError as e:
149
+ r.raise_for_status() # Raise an error for any other status code
150
+ except (ConnectionError, Timeout) as e:
128
151
  if retries >= max_retries - 1:
129
152
  raise e
130
153
  s = 2 ** retries
131
- print(f"Connection error, trying again in {s} seconds...")
154
+ logger.warning(f"Connection or timeout error ({e}), trying again in {s} seconds...")
132
155
  time.sleep(s)
133
156
  retries += 1
134
157
 
@@ -153,7 +176,9 @@ class BallchasingApi:
153
176
  # consumer processing items. When prefetch=False (e.g. deep mode
154
177
  # where the consumer also makes API calls), the next request is
155
178
  # submitted *after* yielding to avoid doubling the request rate.
156
- remaining = params["count"]
179
+ remaining = params.get("count", 0)
180
+ if remaining <= 0:
181
+ return
157
182
 
158
183
  def fetch_page(p):
159
184
  return self._request(url, "GET", params=p).json()
@@ -164,15 +189,19 @@ class BallchasingApi:
164
189
 
165
190
  while remaining > 0:
166
191
  d = future.result()
167
- batch = d["list"][:min(remaining, 200)]
192
+ batch = d.get("list", [])[:min(remaining, 200)]
168
193
  remaining -= len(batch)
169
194
 
170
- has_next = "next" in d and remaining > 0
195
+ next_url = d.get("next")
196
+ has_next = bool(next_url) and remaining > 0
171
197
  if has_next:
172
- next_url = d["next"]
173
- params["after"] = parse_qs(urlparse(next_url).query)["after"][0]
198
+ after_param = parse_qs(urlparse(next_url).query).get("after", [None])[0]
199
+ if after_param is not None:
200
+ params["after"] = after_param
201
+ else:
202
+ has_next = False
174
203
  params["count"] = min(remaining, 200)
175
- if prefetch:
204
+ if prefetch and has_next:
176
205
  future = executor.submit(fetch_page, dict(params))
177
206
 
178
207
  yield from batch
@@ -207,6 +236,7 @@ class BallchasingApi:
207
236
  deep: bool = False,
208
237
  typed: bool | None = None,
209
238
  deduplicate: bool = False,
239
+ disable_prefetch: bool | None = None,
210
240
  ) -> Iterator[dict | ShallowReplay | DeepReplay]:
211
241
  """
212
242
  This endpoint lets you filter and retrieve replays. The implementation returns an iterator.
@@ -242,6 +272,7 @@ class BallchasingApi:
242
272
  :param deep: whether to get full stats for each replay (will be much slower).
243
273
  :param typed: whether to return a typed object (default is self.typed).
244
274
  :param deduplicate: whether to deduplicate replays that seem to be the same game.
275
+ :param disable_prefetch: whether to disable prefetching replays.
245
276
  :return: an iterator over the replays returned by the API.
246
277
  """
247
278
  url = f"{self.base_url}/replays"
@@ -256,12 +287,15 @@ class BallchasingApi:
256
287
  if typed is None:
257
288
  typed = self.typed
258
289
 
259
- iterator = self._iterable_from_request(url, params, prefetch=not deep)
260
- if deep:
261
- iterator = (self.get_replay(r["id"]) for r in iterator)
290
+ if disable_prefetch is None:
291
+ disable_prefetch = deep
292
+
293
+ iterator = self._iterable_from_request(url, params, prefetch=not disable_prefetch)
262
294
  if deduplicate:
263
295
  # Deep replays have match and replay IDs to deduplicate with. For shallow replays we check dates.
264
296
  iterator = deduplicator(iterator, check_dates=not deep)
297
+ if deep:
298
+ iterator = (self.get_replay(r["id"]) for r in iterator)
265
299
  if typed:
266
300
  if deep:
267
301
  iterator = (DeepReplay(**r) for r in iterator)
@@ -336,6 +370,7 @@ class BallchasingApi:
336
370
  sort_dir: AnySortDir = SortDir.DESCENDING,
337
371
  deep: bool = False,
338
372
  typed: bool | None = None,
373
+ disable_prefetch: bool | None = None,
339
374
  ) -> Iterator[dict | ShallowGroup | DeepGroup]:
340
375
  """
341
376
  This endpoint lets you filter and retrieve replay groups.
@@ -354,12 +389,17 @@ class BallchasingApi:
354
389
  :param sort_dir: Sort direction.
355
390
  :param deep: whether to get full stats for each group (will be much slower).
356
391
  :param typed: whether to return a typed object (default is self.typed).
392
+ :param disable_prefetch: whether to disable prefetching.
357
393
  :return: an iterator over the groups returned by the API.
358
394
  """
359
395
  url = f"{self.base_url}/groups/"
360
396
  params = {"name": name, "creator": creator, "group": group, "created-before": to_rfc3339(created_before),
361
397
  "created-after": to_rfc3339(created_after), "count": count, "sort-by": sort_by, "sort-dir": sort_dir}
362
- iterator = self._iterable_from_request(url, params, prefetch=not deep)
398
+
399
+ if disable_prefetch is None:
400
+ disable_prefetch = deep
401
+
402
+ iterator = self._iterable_from_request(url, params, prefetch=not disable_prefetch)
363
403
  if typed is None:
364
404
  typed = self.typed
365
405
  if deep:
@@ -20,13 +20,14 @@ class _BaseModel:
20
20
  value = getattr(self, field.name)
21
21
  if isinstance(value, _BaseModel):
22
22
  result[field.name] = value.to_dict()
23
- elif isinstance(value, list) and len(value) > 0:
24
- result[field.name] = [item.to_dict() if isinstance(item, _BaseModel) else item for item in value]
23
+ elif isinstance(value, list):
24
+ if len(value) > 0:
25
+ result[field.name] = [item.to_dict() if isinstance(item, _BaseModel) else item for item in value]
25
26
  elif isinstance(value, datetime):
26
27
  # Convert datetime objects to rfc3339 formatted strings
27
28
  result[field.name] = to_rfc3339(value)
28
- elif bool(value):
29
- # Only include non-empty values, to match the API's behavior
29
+ elif value is not None:
30
+ # Include non-None values, preserving valid 0 and False values
30
31
  result[field.name] = value
31
32
  return result
32
33
 
@@ -151,7 +152,7 @@ class BasePlayer(_BaseModel, _DictToTypeMixin):
151
152
  return NotImplemented
152
153
 
153
154
  def is_bot(self):
154
- return self.id.id == "" and self.id.platform == ""
155
+ return self.id is None or (self.id.id == "" and self.id.platform == "")
155
156
 
156
157
 
157
158
  @dataclass
@@ -77,7 +77,7 @@ def is_standard_replay(replay, allow_bots=False, allow_splitscreen=False) -> tup
77
77
  if map_id is None or map_id not in Map.STANDARD_MAPS + (Map.NEOTOKYO_TOON_P,):
78
78
  if playlist_id.startswith("ranked"):
79
79
  logging.warning("Replay %s has non-standard map %s in ranked playlist %s",
80
- replay["id"], map_id, playlist_id)
80
+ replay.get("id", "<unknown>"), map_id, playlist_id)
81
81
  return False, f"Map is not standard: {map_id}"
82
82
  if map_id in (Map.PARK_SNOWY_P, Map.UTOPIASTADIUM_SNOW_P, Map.EUROSTADIUM_SNOWNIGHT_P):
83
83
  if playlist_id not in Playlist.RANKED:
@@ -128,7 +128,7 @@ def is_standard_replay(replay, allow_bots=False, allow_splitscreen=False) -> tup
128
128
  if not pid and not allow_bots:
129
129
  return False, f"Player {name} has no ID"
130
130
  # Split screen players get a "player_number" that is not 0
131
- player_number = pid.get("player_number", 0)
131
+ player_number = (pid or {}).get("player_number", 0)
132
132
  if player_number != 0 and not allow_splitscreen:
133
133
  return False, f"Player {name} has player number {player_number}, indicating split-screen"
134
134
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-ballchasing"
7
- version = "0.5.0"
7
+ version = "0.5.2"
8
8
  authors = [
9
9
  { name = "Rolv-Arild Braaten", email = "rolv_arild@hotmail.com" },
10
10
  ]
@@ -29,4 +29,4 @@ dependencies = [
29
29
  where = ["."]
30
30
 
31
31
  [tool.setuptools.package-data]
32
- ballchasing = ["*.tsv"]
32
+ ballchasing = ["*.tsv"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-ballchasing
3
- Version: 0.5.0
3
+ Version: 0.5.2
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
@@ -24,4 +24,5 @@ python_ballchasing.egg-info/top_level.txt
24
24
  scripts/get_maps.py
25
25
  scripts/make_types.py
26
26
  scripts/test.py
27
- scripts/test_typed.py
27
+ scripts/test_typed.py
28
+ tests/test_fixes.py
@@ -0,0 +1,246 @@
1
+ import logging
2
+ from unittest.mock import patch, MagicMock
3
+ import pytest
4
+ from requests import Response
5
+ from requests.exceptions import ConnectionError as RequestsConnectionError, Timeout
6
+
7
+ from ballchasing.api import BallchasingApi, PATRON_RATE_LIMIT_SLEEP_TIMES
8
+ from ballchasing.constants import Map
9
+ from ballchasing.typed.shared import PlayerID, BasePlayer, BaseReplay
10
+ from ballchasing.typed.deep_replay import DeepReplay, TeamCoreStatsDR, PlayerCoreStatsDR
11
+ from ballchasing.util.replays import is_standard_replay
12
+
13
+
14
+ def make_mock_response(status_code=200, json_data=None, headers=None):
15
+ resp = Response()
16
+ resp.status_code = status_code
17
+ if json_data is not None:
18
+ resp._content = (
19
+ bytes(__import__("json").dumps(json_data), "utf-8")
20
+ )
21
+ else:
22
+ resp._content = b"{}"
23
+ if headers:
24
+ resp.headers.update(headers)
25
+ return resp
26
+
27
+
28
+ class TestInitialPingBypass:
29
+ def test_do_initial_ping_false_makes_no_requests(self):
30
+ with patch.object(BallchasingApi, "_request") as mock_request:
31
+ api = BallchasingApi("dummy_key", do_initial_ping=False)
32
+ mock_request.assert_not_called()
33
+ assert api._ping_result is None
34
+
35
+ def test_sleep_time_on_rate_limit_defaults_to_regular_when_not_pinged(self):
36
+ with patch.object(BallchasingApi, "_request") as mock_request:
37
+ api = BallchasingApi("dummy_key", do_initial_ping=False)
38
+ assert api.sleep_time_on_rate_limit == PATRON_RATE_LIMIT_SLEEP_TIMES["regular"]
39
+ mock_request.assert_not_called()
40
+
41
+ def test_sleep_time_on_rate_limit_explicit(self):
42
+ api = BallchasingApi("dummy_key", sleep_time_on_rate_limit=1.5, do_initial_ping=False)
43
+ assert api.sleep_time_on_rate_limit == 1.5
44
+
45
+ def test_sleep_time_updates_after_ping(self):
46
+ api = BallchasingApi("dummy_key", do_initial_ping=False)
47
+ assert api.sleep_time_on_rate_limit == PATRON_RATE_LIMIT_SLEEP_TIMES["regular"]
48
+
49
+ # Simulate ping result later
50
+ api._ping_result = {"type": "gold", "name": "TestUser"}
51
+ assert api.sleep_time_on_rate_limit == PATRON_RATE_LIMIT_SLEEP_TIMES["gold"]
52
+
53
+
54
+ class TestHttpRetryAndLogging:
55
+ @pytest.mark.parametrize("status_code", [502, 503, 504])
56
+ def test_retry_transient_gateway_errors(self, status_code):
57
+ api = BallchasingApi("dummy_key", do_initial_ping=False)
58
+ err_resp = make_mock_response(status_code=status_code)
59
+ ok_resp = make_mock_response(status_code=200, json_data={"success": True})
60
+
61
+ with patch.object(api._session, "request", side_effect=[err_resp, ok_resp]) as mock_req, \
62
+ patch("time.sleep") as mock_sleep:
63
+ res = api._request("/test", "GET")
64
+ assert res.status_code == 200
65
+ assert mock_req.call_count == 2
66
+ mock_sleep.assert_called_once_with(1) # 2 ** 0
67
+
68
+ def test_retry_timeout(self):
69
+ api = BallchasingApi("dummy_key", do_initial_ping=False)
70
+ ok_resp = make_mock_response(status_code=200, json_data={"success": True})
71
+
72
+ with patch.object(api._session, "request", side_effect=[Timeout("timed out"), ok_resp]) as mock_req, \
73
+ patch("time.sleep") as mock_sleep:
74
+ res = api._request("/test", "GET")
75
+ assert res.status_code == 200
76
+ assert mock_req.call_count == 2
77
+ mock_sleep.assert_called_once_with(1)
78
+
79
+ def test_retry_exhaustion_raises(self):
80
+ api = BallchasingApi("dummy_key", do_initial_ping=False)
81
+ err_resp = make_mock_response(status_code=502)
82
+
83
+ with patch.object(api._session, "request", return_value=err_resp), \
84
+ patch("time.sleep"):
85
+ with pytest.raises(RequestsConnectionError):
86
+ api._request("/test", "GET")
87
+
88
+ def test_rate_limit_backoff_retry_after(self):
89
+ api = BallchasingApi("dummy_key", do_initial_ping=False)
90
+ rl_resp = make_mock_response(status_code=429, headers={"Retry-After": "3"})
91
+ ok_resp = make_mock_response(status_code=200, json_data={"ok": True})
92
+
93
+ with patch.object(api._session, "request", side_effect=[rl_resp, ok_resp]), \
94
+ patch("time.sleep") as mock_sleep:
95
+ res = api._request("/test", "GET")
96
+ assert res.status_code == 200
97
+ mock_sleep.assert_called_once_with(3)
98
+
99
+ def test_rate_limit_backoff_default_when_unset(self):
100
+ api = BallchasingApi("dummy_key", sleep_time_on_rate_limit=0, do_initial_ping=False)
101
+ rl_resp = make_mock_response(status_code=429)
102
+ ok_resp = make_mock_response(status_code=200, json_data={"ok": True})
103
+
104
+ with patch.object(api._session, "request", side_effect=[rl_resp, ok_resp]), \
105
+ patch("time.sleep") as mock_sleep:
106
+ res = api._request("/test", "GET")
107
+ assert res.status_code == 200
108
+ assert mock_sleep.call_count == 1
109
+ assert mock_sleep.call_args[0][0] >= 1.0
110
+
111
+ def test_logging_used_on_retry(self, caplog):
112
+ api = BallchasingApi("dummy_key", do_initial_ping=False, print_on_rate_limit=True)
113
+ rl_resp = make_mock_response(status_code=429)
114
+ ok_resp = make_mock_response(status_code=200, json_data={"ok": True})
115
+
116
+ with caplog.at_level(logging.WARNING, logger="ballchasing"), \
117
+ patch.object(api._session, "request", side_effect=[rl_resp, ok_resp]), \
118
+ patch("time.sleep"):
119
+ api._request("/test", "GET")
120
+ assert any("Rate limited" in record.message for record in caplog.records)
121
+
122
+
123
+ class TestDeduplicationBeforeDeepFetch:
124
+ def test_get_replays_deduplicates_before_deep_call(self):
125
+ api = BallchasingApi("dummy_key", do_initial_ping=False)
126
+ replays_batch = [
127
+ {"id": "rep1", "rocket_league_id": "rl1"},
128
+ {"id": "rep1", "rocket_league_id": "rl1"}, # duplicate id
129
+ {"id": "rep2", "rocket_league_id": "rl2"},
130
+ ]
131
+
132
+ def fake_iterable(*args, **kwargs):
133
+ yield from replays_batch
134
+
135
+ with patch.object(api, "_iterable_from_request", side_effect=fake_iterable), \
136
+ patch.object(api, "get_replay", side_effect=lambda rid: {"id": rid, "deep": True}) as mock_get_replay:
137
+ results = list(api.get_replays(deep=True, deduplicate=True))
138
+ assert len(results) == 2
139
+ assert [r["id"] for r in results] == ["rep1", "rep2"]
140
+ # get_replay should only have been called twice (for rep1 and rep2), NOT 3 times
141
+ assert mock_get_replay.call_count == 2
142
+ mock_get_replay.assert_any_call("rep1")
143
+ mock_get_replay.assert_any_call("rep2")
144
+
145
+
146
+ class TestCountZeroIterable:
147
+ def test_count_zero_makes_no_requests(self):
148
+ api = BallchasingApi("dummy_key", do_initial_ping=False)
149
+ with patch.object(api, "_request") as mock_req:
150
+ replays = list(api.get_replays(count=0))
151
+ assert replays == []
152
+ mock_req.assert_not_called()
153
+
154
+ groups = list(api.get_groups(count=0))
155
+ assert groups == []
156
+ mock_req.assert_not_called()
157
+
158
+ def test_next_url_missing_after_param_handled_defensively(self):
159
+ api = BallchasingApi("dummy_key", do_initial_ping=False)
160
+ # First page has 'next' without 'after' query param
161
+ page1 = {"list": [{"id": "r1"}], "next": "https://ballchasing.com/api/replays?other=val"}
162
+
163
+ with patch.object(api, "_request") as mock_req:
164
+ mock_req.return_value = make_mock_response(200, json_data=page1)
165
+ replays = list(api.get_replays(count=10, disable_prefetch=True))
166
+ assert len(replays) == 1
167
+ assert replays[0]["id"] == "r1"
168
+
169
+
170
+ class TestToDictPreservesFalsyValues:
171
+ def test_preserves_zero_and_false(self):
172
+ pid = PlayerID(platform="steam", id="12345", player_number=0)
173
+ d = pid.to_dict()
174
+ assert d["player_number"] == 0
175
+ assert d["id"] == "12345"
176
+ assert d["platform"] == "steam"
177
+
178
+ player = BasePlayer(name="Test", id=pid, pro=False, mvp=False)
179
+ pd = player.to_dict()
180
+ assert pd["pro"] is False
181
+ assert pd["mvp"] is False
182
+
183
+ core = PlayerCoreStatsDR(goals=0, assists=0, saves=0, score=0, mvp=False)
184
+ cd = core.to_dict()
185
+ assert cd["goals"] == 0
186
+ assert cd["assists"] == 0
187
+ assert cd["saves"] == 0
188
+ assert cd["score"] == 0
189
+ assert cd["mvp"] is False
190
+
191
+ replay = BaseReplay(duration=300, overtime=False, overtime_seconds=0)
192
+ rd = replay.to_dict()
193
+ assert rd["overtime"] is False
194
+ assert rd["overtime_seconds"] == 0
195
+ assert rd["duration"] == 300
196
+
197
+ def test_drops_none(self):
198
+ player = BasePlayer(name="Test", rank=None)
199
+ pd = player.to_dict()
200
+ assert "rank" not in pd
201
+
202
+
203
+ class TestNonePlayerIdFixes:
204
+ def test_base_player_is_bot_with_none_id(self):
205
+ player = BasePlayer(name="BotPlayer", id=None)
206
+ assert player.is_bot() is True
207
+
208
+ def test_base_player_is_bot_with_empty_id(self):
209
+ player = BasePlayer(name="BotPlayer", id=PlayerID(platform="", id=""))
210
+ assert player.is_bot() is True
211
+
212
+ def test_base_player_is_not_bot(self):
213
+ player = BasePlayer(name="Human", id=PlayerID(platform="steam", id="123"))
214
+ assert player.is_bot() is False
215
+
216
+ def test_is_standard_replay_with_none_player_id(self):
217
+ # Construct a replay where a player has id: None
218
+ replay = {
219
+ "id": "rep123",
220
+ "playlist_id": "ranked-doubles",
221
+ "map_code": Map.STADIUM_P,
222
+ "duration": 300,
223
+ "blue": {
224
+ "goals": 2,
225
+ "players": [
226
+ {"name": "P1", "start_time": 0, "end_time": 300, "id": {"platform": "steam", "id": "1", "player_number": 0}},
227
+ {"name": "Bot1", "start_time": 0, "end_time": 300, "id": None},
228
+ ]
229
+ },
230
+ "orange": {
231
+ "goals": 1,
232
+ "players": [
233
+ {"name": "P2", "start_time": 0, "end_time": 300, "id": {"platform": "steam", "id": "2", "player_number": 0}},
234
+ {"name": "P3", "start_time": 0, "end_time": 300, "id": {"platform": "steam", "id": "3", "player_number": 0}},
235
+ ]
236
+ }
237
+ }
238
+ # With allow_bots=True, should not crash with AttributeError and should be valid
239
+ valid, reason = is_standard_replay(replay, allow_bots=True)
240
+ assert valid is True
241
+ assert reason == "No issues found"
242
+
243
+ # With allow_bots=False, should return False because Bot1 has no ID, without crashing
244
+ valid_nobots, reason_nobots = is_standard_replay(replay, allow_bots=False)
245
+ assert valid_nobots is False
246
+ assert "Bot1 has no ID" in reason_nobots