python-ballchasing 0.5.1__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.
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/PKG-INFO +1 -1
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/ballchasing/api.py +54 -25
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/ballchasing/typed/shared.py +6 -5
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/ballchasing/util/replays.py +2 -2
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/pyproject.toml +2 -2
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/python_ballchasing.egg-info/PKG-INFO +1 -1
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/python_ballchasing.egg-info/SOURCES.txt +2 -1
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/python_ballchasing.egg-info/top_level.txt +1 -0
- python_ballchasing-0.5.2/tests/test_fixes.py +246 -0
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/LICENSE +0 -0
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/README.md +0 -0
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/ballchasing/__init__.py +0 -0
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/ballchasing/constants.py +0 -0
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/ballchasing/stats_info.tsv +0 -0
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/ballchasing/typed/__init__.py +0 -0
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/ballchasing/typed/deep_group.py +0 -0
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/ballchasing/typed/deep_replay.py +0 -0
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/ballchasing/typed/shallow_group.py +0 -0
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/ballchasing/typed/shallow_replay.py +0 -0
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/ballchasing/util/__init__.py +0 -0
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/ballchasing/util/dates.py +0 -0
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/ballchasing/util/iterators.py +0 -0
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/ballchasing/util/stats.py +0 -0
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/python_ballchasing.egg-info/dependency_links.txt +0 -0
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/python_ballchasing.egg-info/requires.txt +0 -0
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/scripts/get_maps.py +0 -0
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/scripts/make_types.py +0 -0
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/scripts/test.py +0 -0
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/scripts/test_typed.py +0 -0
- {python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/setup.cfg +0 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
192
|
+
batch = d.get("list", [])[:min(remaining, 200)]
|
|
168
193
|
remaining -= len(batch)
|
|
169
194
|
|
|
170
|
-
|
|
195
|
+
next_url = d.get("next")
|
|
196
|
+
has_next = bool(next_url) and remaining > 0
|
|
171
197
|
if has_next:
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
@@ -262,11 +291,11 @@ class BallchasingApi:
|
|
|
262
291
|
disable_prefetch = deep
|
|
263
292
|
|
|
264
293
|
iterator = self._iterable_from_request(url, params, prefetch=not disable_prefetch)
|
|
265
|
-
if deep:
|
|
266
|
-
iterator = (self.get_replay(r["id"]) for r in iterator)
|
|
267
294
|
if deduplicate:
|
|
268
295
|
# Deep replays have match and replay IDs to deduplicate with. For shallow replays we check dates.
|
|
269
296
|
iterator = deduplicator(iterator, check_dates=not deep)
|
|
297
|
+
if deep:
|
|
298
|
+
iterator = (self.get_replay(r["id"]) for r in iterator)
|
|
270
299
|
if typed:
|
|
271
300
|
if deep:
|
|
272
301
|
iterator = (DeepReplay(**r) for r in iterator)
|
|
@@ -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)
|
|
24
|
-
|
|
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
|
|
29
|
-
#
|
|
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
|
|
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.
|
|
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"]
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_ballchasing-0.5.1 → python_ballchasing-0.5.2}/python_ballchasing.egg-info/requires.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|