infinitode.py 1.1.6__tar.gz → 1.2.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.
- {infinitode.py-1.1.6/infinitode.py.egg-info → infinitode_py-1.2.0}/PKG-INFO +43 -6
- infinitode.py-1.1.6/PKG-INFO → infinitode_py-1.2.0/README.md +31 -13
- {infinitode.py-1.1.6 → infinitode_py-1.2.0}/infinitode/__init__.py +2 -2
- {infinitode.py-1.1.6 → infinitode_py-1.2.0}/infinitode/core.py +198 -72
- {infinitode.py-1.1.6 → infinitode_py-1.2.0}/infinitode/errors.py +14 -0
- {infinitode.py-1.1.6 → infinitode_py-1.2.0}/infinitode/player.py +13 -2
- infinitode.py-1.1.6/README.md → infinitode_py-1.2.0/infinitode.py.egg-info/PKG-INFO +50 -1
- {infinitode.py-1.1.6 → infinitode_py-1.2.0}/infinitode.py.egg-info/SOURCES.txt +2 -1
- infinitode_py-1.2.0/tests/test_core.py +235 -0
- {infinitode.py-1.1.6 → infinitode_py-1.2.0}/infinitode/badge.py +0 -0
- {infinitode.py-1.1.6 → infinitode_py-1.2.0}/infinitode/leaderboard.py +0 -0
- {infinitode.py-1.1.6 → infinitode_py-1.2.0}/infinitode/py.typed +0 -0
- {infinitode.py-1.1.6 → infinitode_py-1.2.0}/infinitode/score.py +0 -0
- {infinitode.py-1.1.6 → infinitode_py-1.2.0}/infinitode/utils.py +0 -0
- {infinitode.py-1.1.6 → infinitode_py-1.2.0}/infinitode.py.egg-info/dependency_links.txt +0 -0
- {infinitode.py-1.1.6 → infinitode_py-1.2.0}/infinitode.py.egg-info/requires.txt +0 -0
- {infinitode.py-1.1.6 → infinitode_py-1.2.0}/infinitode.py.egg-info/top_level.txt +0 -0
- {infinitode.py-1.1.6 → infinitode_py-1.2.0}/setup.cfg +0 -0
- {infinitode.py-1.1.6 → infinitode_py-1.2.0}/setup.py +0 -0
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: infinitode.py
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: A python wrapper for the Infinitode 2 API.
|
|
5
5
|
Home-page: https://github.com/Sprylos/infinitode.py
|
|
6
6
|
Author: Sprylos
|
|
7
7
|
License: MIT
|
|
8
|
-
Platform: UNKNOWN
|
|
9
8
|
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: aiohttp
|
|
10
|
+
Requires-Dist: lxml
|
|
11
|
+
Requires-Dist: bs4
|
|
12
|
+
Dynamic: author
|
|
13
|
+
Dynamic: description
|
|
14
|
+
Dynamic: description-content-type
|
|
15
|
+
Dynamic: home-page
|
|
16
|
+
Dynamic: license
|
|
17
|
+
Dynamic: requires-dist
|
|
18
|
+
Dynamic: summary
|
|
10
19
|
|
|
11
20
|
# Infinitode.py
|
|
12
21
|
|
|
@@ -53,6 +62,11 @@ Parameters:
|
|
|
53
62
|
- `mode` (optional): One of `'score'`, `'waves'`. Defaults to `'score'`.
|
|
54
63
|
- `difficulty` (optional): One of `'EASY'`, `'NORMAL'`, `'ENDLESS_I'`. Defaults to `'NORMAL'`.
|
|
55
64
|
|
|
65
|
+
The immutable `infinitode.SUPPORTED_MAPS`, `infinitode.SUPPORTED_MODES`, and
|
|
66
|
+
`infinitode.SUPPORTED_DIFFICULTIES` tuples contain the values accepted by the
|
|
67
|
+
current library. `infinitode.GAME_API_VERSION` exposes the game API version used
|
|
68
|
+
for JSON API calls.
|
|
69
|
+
|
|
56
70
|
#### Player Score on a Specific Map
|
|
57
71
|
|
|
58
72
|
Retrieve the score of a specific player on a particular map.
|
|
@@ -161,7 +175,9 @@ print(len(top_10))
|
|
|
161
175
|
Retrieve detailed player information.
|
|
162
176
|
|
|
163
177
|
```python
|
|
164
|
-
player = await API.player("U-E9BP-FSN9-H6ENMQ")
|
|
178
|
+
player = await API.player(playerid="U-E9BP-FSN9-H6ENMQ")
|
|
179
|
+
# Or look up a case-insensitive, exact nickname:
|
|
180
|
+
player = await API.player(nickname="EuphoRowan")
|
|
165
181
|
```
|
|
166
182
|
|
|
167
183
|
Attributes include:
|
|
@@ -186,6 +202,29 @@ await player.fetch_skill_point(API)
|
|
|
186
202
|
print(player.daily_quest.rank, player.skill_point.score)
|
|
187
203
|
```
|
|
188
204
|
|
|
205
|
+
An invalid argument raises `infinitode.errors.BadArgument`, and an exact lookup
|
|
206
|
+
with no matching player raises its `PlayerNotFound` subclass. HTTP and JSON API
|
|
207
|
+
failures raise `APIError`; unexpected changes to parsed HTML raise `ParseError`.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
### Experimental features
|
|
212
|
+
|
|
213
|
+
`Session.search_players()` performs an unauthenticated, case-insensitive
|
|
214
|
+
substring search of player nicknames:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
players = await API.search_players("eupho", limit=20)
|
|
218
|
+
for result in players:
|
|
219
|
+
print(result.playerid, result.nickname, result.level, result.has_avatar)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Each `PlayerSummary` contains only the verified `playerid`, `nickname`, `level`,
|
|
223
|
+
and `has_avatar` fields. The upstream page returns at most 100 results and does
|
|
224
|
+
not offer pagination. This feature parses an HTML page rather than a stable JSON
|
|
225
|
+
API, so upstream markup changes may cause `ParseError` until the parser is
|
|
226
|
+
updated.
|
|
227
|
+
|
|
189
228
|
---
|
|
190
229
|
|
|
191
230
|
### Beta Scores
|
|
@@ -224,5 +263,3 @@ asyncio.run(main())
|
|
|
224
263
|
|
|
225
264
|
- Some data (e.g., seasonal leaderboard, players) require additional parsing and will take longer to process.
|
|
226
265
|
- Do not abuse this API wrapper for any kind of malicious action.
|
|
227
|
-
|
|
228
|
-
|
|
@@ -1,13 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.1
|
|
2
|
-
Name: infinitode.py
|
|
3
|
-
Version: 1.1.6
|
|
4
|
-
Summary: A python wrapper for the Infinitode 2 API.
|
|
5
|
-
Home-page: https://github.com/Sprylos/infinitode.py
|
|
6
|
-
Author: Sprylos
|
|
7
|
-
License: MIT
|
|
8
|
-
Platform: UNKNOWN
|
|
9
|
-
Description-Content-Type: text/markdown
|
|
10
|
-
|
|
11
1
|
# Infinitode.py
|
|
12
2
|
|
|
13
3
|
An asynchronous Python wrapper for the Infinitode-2 API using `async`-`await` syntax.
|
|
@@ -53,6 +43,11 @@ Parameters:
|
|
|
53
43
|
- `mode` (optional): One of `'score'`, `'waves'`. Defaults to `'score'`.
|
|
54
44
|
- `difficulty` (optional): One of `'EASY'`, `'NORMAL'`, `'ENDLESS_I'`. Defaults to `'NORMAL'`.
|
|
55
45
|
|
|
46
|
+
The immutable `infinitode.SUPPORTED_MAPS`, `infinitode.SUPPORTED_MODES`, and
|
|
47
|
+
`infinitode.SUPPORTED_DIFFICULTIES` tuples contain the values accepted by the
|
|
48
|
+
current library. `infinitode.GAME_API_VERSION` exposes the game API version used
|
|
49
|
+
for JSON API calls.
|
|
50
|
+
|
|
56
51
|
#### Player Score on a Specific Map
|
|
57
52
|
|
|
58
53
|
Retrieve the score of a specific player on a particular map.
|
|
@@ -161,7 +156,9 @@ print(len(top_10))
|
|
|
161
156
|
Retrieve detailed player information.
|
|
162
157
|
|
|
163
158
|
```python
|
|
164
|
-
player = await API.player("U-E9BP-FSN9-H6ENMQ")
|
|
159
|
+
player = await API.player(playerid="U-E9BP-FSN9-H6ENMQ")
|
|
160
|
+
# Or look up a case-insensitive, exact nickname:
|
|
161
|
+
player = await API.player(nickname="EuphoRowan")
|
|
165
162
|
```
|
|
166
163
|
|
|
167
164
|
Attributes include:
|
|
@@ -186,6 +183,29 @@ await player.fetch_skill_point(API)
|
|
|
186
183
|
print(player.daily_quest.rank, player.skill_point.score)
|
|
187
184
|
```
|
|
188
185
|
|
|
186
|
+
An invalid argument raises `infinitode.errors.BadArgument`, and an exact lookup
|
|
187
|
+
with no matching player raises its `PlayerNotFound` subclass. HTTP and JSON API
|
|
188
|
+
failures raise `APIError`; unexpected changes to parsed HTML raise `ParseError`.
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
### Experimental features
|
|
193
|
+
|
|
194
|
+
`Session.search_players()` performs an unauthenticated, case-insensitive
|
|
195
|
+
substring search of player nicknames:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
players = await API.search_players("eupho", limit=20)
|
|
199
|
+
for result in players:
|
|
200
|
+
print(result.playerid, result.nickname, result.level, result.has_avatar)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Each `PlayerSummary` contains only the verified `playerid`, `nickname`, `level`,
|
|
204
|
+
and `has_avatar` fields. The upstream page returns at most 100 results and does
|
|
205
|
+
not offer pagination. This feature parses an HTML page rather than a stable JSON
|
|
206
|
+
API, so upstream markup changes may cause `ParseError` until the parser is
|
|
207
|
+
updated.
|
|
208
|
+
|
|
189
209
|
---
|
|
190
210
|
|
|
191
211
|
### Beta Scores
|
|
@@ -224,5 +244,3 @@ asyncio.run(main())
|
|
|
224
244
|
|
|
225
245
|
- Some data (e.g., seasonal leaderboard, players) require additional parsing and will take longer to process.
|
|
226
246
|
- Do not abuse this API wrapper for any kind of malicious action.
|
|
227
|
-
|
|
228
|
-
|
|
@@ -7,10 +7,10 @@ An asynchronous wrapper for the Infinitode API.
|
|
|
7
7
|
|
|
8
8
|
__author__ = "Sprylos"
|
|
9
9
|
__title__ = "infinitode.py"
|
|
10
|
-
__version__ = "1.
|
|
10
|
+
__version__ = "1.2.0"
|
|
11
11
|
__license__ = """MIT License
|
|
12
12
|
|
|
13
|
-
Copyright (c)
|
|
13
|
+
Copyright (c) 2026 Sprylos
|
|
14
14
|
|
|
15
15
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
16
16
|
of this software and associated documentation files (the "Software"), to deal
|
|
@@ -17,21 +17,29 @@ import aiohttp
|
|
|
17
17
|
from bs4 import BeautifulSoup, Comment
|
|
18
18
|
|
|
19
19
|
# local
|
|
20
|
-
from .errors import APIError, BadArgument
|
|
20
|
+
from .errors import APIError, BadArgument, ParseError, PlayerNotFound
|
|
21
21
|
from .leaderboard import Leaderboard
|
|
22
|
-
from .player import Player
|
|
22
|
+
from .player import Player, PlayerSummary
|
|
23
23
|
from .score import Score
|
|
24
24
|
from .utils import async_expiring_cache, try_int
|
|
25
25
|
|
|
26
26
|
|
|
27
|
-
__all__ = (
|
|
27
|
+
__all__ = (
|
|
28
|
+
"Session",
|
|
29
|
+
"GAME_API_VERSION",
|
|
30
|
+
"SUPPORTED_MAPS",
|
|
31
|
+
"SUPPORTED_MODES",
|
|
32
|
+
"SUPPORTED_DIFFICULTIES",
|
|
33
|
+
)
|
|
28
34
|
|
|
29
35
|
LOG = logging.getLogger(__name__)
|
|
30
36
|
|
|
31
37
|
ID_REGEX = re.compile(r"U-([A-Z0-9]{4}-){2}[A-Z0-9]{6}")
|
|
38
|
+
GAME_API_VERSION = 282
|
|
32
39
|
|
|
33
40
|
# fmt: off
|
|
34
|
-
|
|
41
|
+
SUPPORTED_MAPS = (
|
|
42
|
+
'0.1', '0.2', '0.3', '0.4',
|
|
35
43
|
'1.1', '1.2', '1.3', '1.4', '1.5', '1.6', '1.7', '1.8', '1.b1',
|
|
36
44
|
'2.1', '2.2', '2.3', '2.4', '2.5', '2.6', '2.7', '2.8', '2.b1',
|
|
37
45
|
'3.1', '3.2', '3.3', '3.4', '3.5', '3.6', '3.7', '3.8', '3.b1',
|
|
@@ -40,10 +48,14 @@ LEVELS = (
|
|
|
40
48
|
'6.1', '6.2', '6.3', '6.4', '6.5', '6.6', 'rumble', 'dev', 'zecred',
|
|
41
49
|
'DQ1', 'DQ3', 'DQ4', 'DQ5', 'DQ7', 'DQ8', 'DQ9', 'DQ10', 'DQ11', 'DQ12',
|
|
42
50
|
)
|
|
43
|
-
|
|
44
|
-
|
|
51
|
+
SUPPORTED_MODES = ('score', 'waves')
|
|
52
|
+
SUPPORTED_DIFFICULTIES = ('EASY', 'NORMAL', 'ENDLESS_I')
|
|
45
53
|
# fmt: on
|
|
46
54
|
|
|
55
|
+
LEVELS = SUPPORTED_MAPS
|
|
56
|
+
MODES = SUPPORTED_MODES
|
|
57
|
+
DIFFICULTIES = SUPPORTED_DIFFICULTIES
|
|
58
|
+
|
|
47
59
|
|
|
48
60
|
def base_url(beta: bool = False) -> str:
|
|
49
61
|
return f"https://{'beta.' if beta else ''}infinitode.prineside.com/"
|
|
@@ -74,14 +86,16 @@ class Session:
|
|
|
74
86
|
difficulty: Optional[str] = None,
|
|
75
87
|
) -> None:
|
|
76
88
|
if mapname is not None and str(mapname) not in LEVELS:
|
|
77
|
-
raise BadArgument("Invalid map: "
|
|
78
|
-
if playerid is not None and
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
raise BadArgument(f"Invalid
|
|
82
|
-
if
|
|
89
|
+
raise BadArgument(f"Invalid map: {mapname}")
|
|
90
|
+
if playerid is not None and (
|
|
91
|
+
not isinstance(playerid, str) or ID_REGEX.fullmatch(playerid) is None
|
|
92
|
+
):
|
|
93
|
+
raise BadArgument(f"Invalid playerid: {playerid}")
|
|
94
|
+
if mode is not None and mode not in MODES:
|
|
95
|
+
raise BadArgument(f"Invalid mode (must be one of {MODES}): {mode}")
|
|
96
|
+
if difficulty is not None and difficulty not in DIFFICULTIES:
|
|
83
97
|
raise BadArgument(
|
|
84
|
-
f"Invalid difficulty (must be one of {DIFFICULTIES}): "
|
|
98
|
+
f"Invalid difficulty (must be one of {DIFFICULTIES}): {difficulty}"
|
|
85
99
|
)
|
|
86
100
|
|
|
87
101
|
# not being more specific with the payload type
|
|
@@ -90,21 +104,29 @@ class Session:
|
|
|
90
104
|
self, arg: str, data: Optional[Dict[str, Any]] = None, *, beta: bool = False
|
|
91
105
|
) -> Dict[str, Any]:
|
|
92
106
|
"""Internal post method to communicate with Rainy's API"""
|
|
93
|
-
url = base_url(beta) +
|
|
107
|
+
url = base_url(beta) + (
|
|
108
|
+
f"?m=api&a={arg}&apiv=1&g=com.prineside.tdi2&v={GAME_API_VERSION}"
|
|
109
|
+
)
|
|
94
110
|
LOG.info("Sending POST request %s with data %s", arg, data)
|
|
95
|
-
|
|
96
|
-
|
|
111
|
+
try:
|
|
112
|
+
async with self._session.post(url, data=data) as r:
|
|
97
113
|
r.raise_for_status()
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
114
|
+
try:
|
|
115
|
+
payload: Dict[str, Any] = await r.json()
|
|
116
|
+
except (ValueError, TypeError) as exc:
|
|
117
|
+
raise APIError("Invalid JSON response from server") from exc
|
|
118
|
+
except aiohttp.ClientError as exc:
|
|
119
|
+
raise APIError("Something went wrong. Try again later") from exc
|
|
120
|
+
|
|
121
|
+
if not isinstance(payload, dict):
|
|
122
|
+
raise APIError("Invalid JSON response from server")
|
|
123
|
+
LOG.debug("Response to POST request %s: %s", arg, payload)
|
|
124
|
+
|
|
125
|
+
if payload.get("status") == "success":
|
|
126
|
+
return payload
|
|
127
|
+
raise APIError(
|
|
128
|
+
f'Error response from server: {payload.get("message", "unknown error")}'
|
|
129
|
+
)
|
|
108
130
|
|
|
109
131
|
@async_expiring_cache()
|
|
110
132
|
async def leaderboards_rank(
|
|
@@ -282,40 +304,62 @@ class Session:
|
|
|
282
304
|
async def seasonal_leaderboard(self, *, beta: bool = False) -> Leaderboard:
|
|
283
305
|
"""
|
|
284
306
|
Retrieves the season Leaderboard.
|
|
285
|
-
The leaderboard contains the top
|
|
307
|
+
The leaderboard contains the top 200 scores in the season.
|
|
286
308
|
This coroutine never takes arguments.
|
|
287
309
|
"""
|
|
288
310
|
url = base_url(beta) + "xdx/?url=seasonal_leaderboard"
|
|
289
311
|
LOG.info("Sending GET request to %s", url)
|
|
290
312
|
|
|
291
|
-
r = await self._session.get(url=url)
|
|
292
313
|
try:
|
|
314
|
+
r = await self._session.get(url=url)
|
|
293
315
|
r.raise_for_status()
|
|
294
|
-
|
|
295
|
-
|
|
316
|
+
content = await r.text()
|
|
317
|
+
except aiohttp.ClientError as exc:
|
|
318
|
+
raise APIError("Bad Gateway.") from exc
|
|
296
319
|
|
|
297
|
-
seasonal = BeautifulSoup(
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
320
|
+
seasonal = BeautifulSoup(content, features="lxml")
|
|
321
|
+
try:
|
|
322
|
+
season_label = seasonal.select_one('label[i18n="season_formatted"]')
|
|
323
|
+
count_label = seasonal.select_one('label[i18n="player_count_formatted"]')
|
|
324
|
+
player_labels = seasonal.select('label[color="LIGHT_BLUE:P300"]')
|
|
325
|
+
score_labels = seasonal.select(
|
|
326
|
+
'label[nowrap="true"][text-align="right"]'
|
|
327
|
+
)
|
|
328
|
+
rows = seasonal.select('div[x="90"]')
|
|
329
|
+
if season_label is None or count_label is None:
|
|
330
|
+
raise ValueError("missing seasonal metadata")
|
|
331
|
+
if len(player_labels) != len(rows) or len(score_labels) < len(rows):
|
|
332
|
+
raise ValueError("incomplete seasonal rows")
|
|
333
|
+
season = int(str(season_label["i18nf"]).replace('["', '').replace('"]', ''))
|
|
334
|
+
player_count = int(
|
|
335
|
+
str(count_label["i18nf"])
|
|
336
|
+
.replace('["', '')
|
|
337
|
+
.replace('"]', '')
|
|
338
|
+
.replace(',', '')
|
|
339
|
+
)
|
|
340
|
+
scores = [
|
|
341
|
+
{
|
|
342
|
+
"playerid": str(player_labels[x]["click"]).split("id=", 1)[1],
|
|
343
|
+
"nickname": player_labels[x].text,
|
|
344
|
+
"score": score_labels[x].text.replace(",", ""),
|
|
345
|
+
}
|
|
346
|
+
for x in range(len(rows))
|
|
347
|
+
]
|
|
348
|
+
return Leaderboard.from_payload(
|
|
349
|
+
"seasonal_leaderboard",
|
|
350
|
+
"season",
|
|
351
|
+
"score",
|
|
352
|
+
"NORMAL",
|
|
353
|
+
None,
|
|
354
|
+
{
|
|
355
|
+
"status": "success",
|
|
356
|
+
"player": {"total": player_count},
|
|
357
|
+
"leaderboards": scores,
|
|
358
|
+
},
|
|
359
|
+
season=season,
|
|
360
|
+
)
|
|
361
|
+
except (KeyError, IndexError, TypeError, ValueError) as exc:
|
|
362
|
+
raise ParseError("Could not parse seasonal leaderboard HTML") from exc
|
|
319
363
|
|
|
320
364
|
@async_expiring_cache()
|
|
321
365
|
async def player(
|
|
@@ -326,38 +370,120 @@ class Session:
|
|
|
326
370
|
beta: bool = False,
|
|
327
371
|
) -> Player:
|
|
328
372
|
"""
|
|
329
|
-
Retrieves
|
|
330
|
-
|
|
373
|
+
Retrieves a Player by exact player ID or nickname.
|
|
374
|
+
|
|
375
|
+
Nickname matching is case-insensitive. Exactly one lookup value is required.
|
|
331
376
|
"""
|
|
332
|
-
if playerid
|
|
333
|
-
raise BadArgument("
|
|
377
|
+
if (playerid is None) == (nickname is None):
|
|
378
|
+
raise BadArgument("Specify exactly one of playerid or nickname.")
|
|
334
379
|
|
|
335
380
|
url = base_url(beta)
|
|
336
|
-
if nickname:
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
url = url + "xdx/index.php?url=profile/view&id=" + playerid
|
|
381
|
+
if nickname is not None:
|
|
382
|
+
if not isinstance(nickname, str) or not nickname.strip():
|
|
383
|
+
raise BadArgument("Nickname must be a non-empty string.")
|
|
384
|
+
params = {"url": "profile/view", "nickname": nickname}
|
|
341
385
|
else:
|
|
342
|
-
|
|
386
|
+
self._kwarg_check(playerid=playerid)
|
|
387
|
+
params = {"url": "profile/view", "id": playerid}
|
|
343
388
|
|
|
344
|
-
|
|
389
|
+
url += "xdx/index.php"
|
|
390
|
+
LOG.info("Sending GET request to %s with params %s", url, params)
|
|
345
391
|
|
|
346
|
-
r = await self._session.get(url=url)
|
|
347
392
|
try:
|
|
393
|
+
r = await self._session.get(url=url, params=params)
|
|
348
394
|
r.raise_for_status()
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
395
|
+
content = await r.text()
|
|
396
|
+
except aiohttp.ClientError as exc:
|
|
397
|
+
raise APIError("Bad Gateway.") from exc
|
|
398
|
+
not_found = BeautifulSoup(content, features="lxml").select_one("label")
|
|
399
|
+
if (
|
|
400
|
+
not_found is not None
|
|
401
|
+
and not_found.get_text(strip=True) == "Player not found:"
|
|
402
|
+
):
|
|
403
|
+
raise PlayerNotFound(f"Player not found: {playerid or nickname}")
|
|
404
|
+
|
|
405
|
+
loop = asyncio.get_running_loop()
|
|
353
406
|
try:
|
|
354
407
|
return await loop.run_in_executor(
|
|
355
|
-
None, self._parse_player,
|
|
408
|
+
None, self._parse_player, content, beta
|
|
356
409
|
)
|
|
357
410
|
except Exception as exc:
|
|
358
|
-
raise
|
|
359
|
-
|
|
360
|
-
|
|
411
|
+
raise ParseError("Could not parse player profile HTML") from exc
|
|
412
|
+
|
|
413
|
+
@async_expiring_cache()
|
|
414
|
+
async def search_players(
|
|
415
|
+
self, query: str, *, limit: int = 20, beta: bool = False
|
|
416
|
+
) -> list[PlayerSummary]:
|
|
417
|
+
"""Searches player nicknames by case-insensitive substring match."""
|
|
418
|
+
if not isinstance(query, str) or not query.strip():
|
|
419
|
+
raise BadArgument("Search query must be a non-empty string.")
|
|
420
|
+
if (
|
|
421
|
+
isinstance(limit, bool)
|
|
422
|
+
or not isinstance(limit, int)
|
|
423
|
+
or not 1 <= limit <= 100
|
|
424
|
+
):
|
|
425
|
+
raise BadArgument("Search limit must be an integer from 1 through 100.")
|
|
426
|
+
|
|
427
|
+
url = base_url(beta) + "xdx/index.php"
|
|
428
|
+
params = {"url": "profile/list", "nickname": query}
|
|
429
|
+
LOG.info("Sending GET request to %s with params %s", url, params)
|
|
430
|
+
try:
|
|
431
|
+
r = await self._session.get(url=url, params=params)
|
|
432
|
+
r.raise_for_status()
|
|
433
|
+
content = await r.text()
|
|
434
|
+
except aiohttp.ClientError as exc:
|
|
435
|
+
raise APIError("Bad Gateway.") from exc
|
|
436
|
+
loop = asyncio.get_running_loop()
|
|
437
|
+
try:
|
|
438
|
+
results = await loop.run_in_executor(
|
|
439
|
+
None, self._parse_player_search, content
|
|
440
|
+
)
|
|
441
|
+
except Exception as exc:
|
|
442
|
+
raise ParseError("Could not parse player search HTML") from exc
|
|
443
|
+
return results[:limit]
|
|
444
|
+
|
|
445
|
+
@staticmethod
|
|
446
|
+
def _parse_player_search(content: str) -> list[PlayerSummary]:
|
|
447
|
+
data = BeautifulSoup(content, features="lxml")
|
|
448
|
+
found_label = next(
|
|
449
|
+
(
|
|
450
|
+
label
|
|
451
|
+
for label in data.select("label")
|
|
452
|
+
if label.get_text(strip=True).startswith("Players found:")
|
|
453
|
+
),
|
|
454
|
+
None,
|
|
455
|
+
)
|
|
456
|
+
if found_label is None:
|
|
457
|
+
raise ValueError("missing player search result count")
|
|
458
|
+
found_count = int(
|
|
459
|
+
found_label.get_text(strip=True).split(":", 1)[1].replace(",", "")
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
results: list[PlayerSummary] = []
|
|
463
|
+
for row in data.select('div[width="960"][height="64"]'):
|
|
464
|
+
profile = row.select_one('label[click*="profile/view"][click*="id="]')
|
|
465
|
+
level_badge = row.select_one('div[data^="player-level-badge:"]')
|
|
466
|
+
avatar = row.select_one('img[src*="/avatars/"]')
|
|
467
|
+
if profile is None or level_badge is None or avatar is None:
|
|
468
|
+
raise ValueError("incomplete player search row")
|
|
469
|
+
click = str(profile["click"])
|
|
470
|
+
playerid = click.split("id=", 1)[1].split("&", 1)[0]
|
|
471
|
+
level = int(str(level_badge["data"]).split(":", 1)[1])
|
|
472
|
+
avatar_src = str(avatar["src"])
|
|
473
|
+
nickname = profile.get_text()
|
|
474
|
+
if ID_REGEX.fullmatch(playerid) is None or not nickname:
|
|
475
|
+
raise ValueError("invalid player search row")
|
|
476
|
+
results.append(
|
|
477
|
+
PlayerSummary(
|
|
478
|
+
playerid=playerid,
|
|
479
|
+
nickname=nickname,
|
|
480
|
+
level=level,
|
|
481
|
+
has_avatar=not avatar_src.endswith("/guest-64.png"),
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
if len(results) != min(found_count, 100):
|
|
485
|
+
raise ValueError("incomplete player search results")
|
|
486
|
+
return results
|
|
361
487
|
|
|
362
488
|
@classmethod
|
|
363
489
|
def _parse_player(cls, content: str, beta: bool) -> Player:
|
|
@@ -4,6 +4,8 @@ __all__ = (
|
|
|
4
4
|
"InfinitodeError",
|
|
5
5
|
"APIError",
|
|
6
6
|
"BadArgument",
|
|
7
|
+
"PlayerNotFound",
|
|
8
|
+
"ParseError",
|
|
7
9
|
)
|
|
8
10
|
|
|
9
11
|
|
|
@@ -23,3 +25,15 @@ class BadArgument(InfinitodeError):
|
|
|
23
25
|
"""Error raised when an invalid argument is passed."""
|
|
24
26
|
|
|
25
27
|
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PlayerNotFound(BadArgument):
|
|
31
|
+
"""Error raised when a player lookup has no exact match."""
|
|
32
|
+
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ParseError(InfinitodeError):
|
|
37
|
+
"""Error raised when an HTML response cannot be parsed."""
|
|
38
|
+
|
|
39
|
+
pass
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
# std
|
|
4
|
+
from dataclasses import dataclass
|
|
4
5
|
from typing import Any, Dict, Optional, Union, Tuple, TYPE_CHECKING
|
|
5
6
|
|
|
6
7
|
# local
|
|
@@ -12,12 +13,22 @@ if TYPE_CHECKING:
|
|
|
12
13
|
from .core import Session
|
|
13
14
|
|
|
14
15
|
|
|
15
|
-
__all__ = ("Player",)
|
|
16
|
+
__all__ = ("Player", "PlayerSummary")
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
AVATAR_URL = "https://storage.prineside.com/files/i2{}/avatars/{}-128.png"
|
|
19
20
|
|
|
20
21
|
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class PlayerSummary:
|
|
24
|
+
"""A player returned by the experimental nickname search."""
|
|
25
|
+
|
|
26
|
+
playerid: str
|
|
27
|
+
nickname: str
|
|
28
|
+
level: int
|
|
29
|
+
has_avatar: bool
|
|
30
|
+
|
|
31
|
+
|
|
21
32
|
class Player:
|
|
22
33
|
"""Represents an in-game Player."""
|
|
23
34
|
|
|
@@ -180,7 +191,7 @@ class Player:
|
|
|
180
191
|
|
|
181
192
|
@property
|
|
182
193
|
def skill_point(self):
|
|
183
|
-
"""
|
|
194
|
+
"""Return the skill point score; error if it has not been fetched."""
|
|
184
195
|
if self._skill_point is MISSING:
|
|
185
196
|
raise InfinitodeError(
|
|
186
197
|
"This score has not been fetched yet. Use ~.fetch_skill_point first."
|
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: infinitode.py
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: A python wrapper for the Infinitode 2 API.
|
|
5
|
+
Home-page: https://github.com/Sprylos/infinitode.py
|
|
6
|
+
Author: Sprylos
|
|
7
|
+
License: MIT
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: aiohttp
|
|
10
|
+
Requires-Dist: lxml
|
|
11
|
+
Requires-Dist: bs4
|
|
12
|
+
Dynamic: author
|
|
13
|
+
Dynamic: description
|
|
14
|
+
Dynamic: description-content-type
|
|
15
|
+
Dynamic: home-page
|
|
16
|
+
Dynamic: license
|
|
17
|
+
Dynamic: requires-dist
|
|
18
|
+
Dynamic: summary
|
|
19
|
+
|
|
1
20
|
# Infinitode.py
|
|
2
21
|
|
|
3
22
|
An asynchronous Python wrapper for the Infinitode-2 API using `async`-`await` syntax.
|
|
@@ -43,6 +62,11 @@ Parameters:
|
|
|
43
62
|
- `mode` (optional): One of `'score'`, `'waves'`. Defaults to `'score'`.
|
|
44
63
|
- `difficulty` (optional): One of `'EASY'`, `'NORMAL'`, `'ENDLESS_I'`. Defaults to `'NORMAL'`.
|
|
45
64
|
|
|
65
|
+
The immutable `infinitode.SUPPORTED_MAPS`, `infinitode.SUPPORTED_MODES`, and
|
|
66
|
+
`infinitode.SUPPORTED_DIFFICULTIES` tuples contain the values accepted by the
|
|
67
|
+
current library. `infinitode.GAME_API_VERSION` exposes the game API version used
|
|
68
|
+
for JSON API calls.
|
|
69
|
+
|
|
46
70
|
#### Player Score on a Specific Map
|
|
47
71
|
|
|
48
72
|
Retrieve the score of a specific player on a particular map.
|
|
@@ -151,7 +175,9 @@ print(len(top_10))
|
|
|
151
175
|
Retrieve detailed player information.
|
|
152
176
|
|
|
153
177
|
```python
|
|
154
|
-
player = await API.player("U-E9BP-FSN9-H6ENMQ")
|
|
178
|
+
player = await API.player(playerid="U-E9BP-FSN9-H6ENMQ")
|
|
179
|
+
# Or look up a case-insensitive, exact nickname:
|
|
180
|
+
player = await API.player(nickname="EuphoRowan")
|
|
155
181
|
```
|
|
156
182
|
|
|
157
183
|
Attributes include:
|
|
@@ -176,6 +202,29 @@ await player.fetch_skill_point(API)
|
|
|
176
202
|
print(player.daily_quest.rank, player.skill_point.score)
|
|
177
203
|
```
|
|
178
204
|
|
|
205
|
+
An invalid argument raises `infinitode.errors.BadArgument`, and an exact lookup
|
|
206
|
+
with no matching player raises its `PlayerNotFound` subclass. HTTP and JSON API
|
|
207
|
+
failures raise `APIError`; unexpected changes to parsed HTML raise `ParseError`.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
### Experimental features
|
|
212
|
+
|
|
213
|
+
`Session.search_players()` performs an unauthenticated, case-insensitive
|
|
214
|
+
substring search of player nicknames:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
players = await API.search_players("eupho", limit=20)
|
|
218
|
+
for result in players:
|
|
219
|
+
print(result.playerid, result.nickname, result.level, result.has_avatar)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Each `PlayerSummary` contains only the verified `playerid`, `nickname`, `level`,
|
|
223
|
+
and `has_avatar` fields. The upstream page returns at most 100 results and does
|
|
224
|
+
not offer pagination. This feature parses an HTML page rather than a stable JSON
|
|
225
|
+
API, so upstream markup changes may cause `ParseError` until the parser is
|
|
226
|
+
updated.
|
|
227
|
+
|
|
179
228
|
---
|
|
180
229
|
|
|
181
230
|
### Beta Scores
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
6
|
+
|
|
7
|
+
import infinitode
|
|
8
|
+
from infinitode.core import Session
|
|
9
|
+
from infinitode.errors import APIError, BadArgument, ParseError, PlayerNotFound
|
|
10
|
+
from infinitode.player import Player, PlayerSummary
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
FIXTURES = Path(__file__).parent / "fixtures"
|
|
14
|
+
PLAYER_ID = "U-ABCD-EFGH-IJKLMN"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fixture(name):
|
|
18
|
+
return (FIXTURES / name).read_text(encoding="utf-8")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FakeResponse:
|
|
22
|
+
def __init__(self, *, text="", payload=None, error=None):
|
|
23
|
+
self._text = text
|
|
24
|
+
self._payload = payload
|
|
25
|
+
self._error = error
|
|
26
|
+
|
|
27
|
+
async def __aenter__(self):
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
async def __aexit__(self, *args):
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
def raise_for_status(self):
|
|
34
|
+
if self._error:
|
|
35
|
+
raise self._error
|
|
36
|
+
|
|
37
|
+
async def text(self):
|
|
38
|
+
return self._text
|
|
39
|
+
|
|
40
|
+
async def json(self):
|
|
41
|
+
if isinstance(self._payload, Exception):
|
|
42
|
+
raise self._payload
|
|
43
|
+
return self._payload
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FakeClient:
|
|
47
|
+
def __init__(self, responses):
|
|
48
|
+
self.responses = list(responses)
|
|
49
|
+
self.get_calls = []
|
|
50
|
+
self.post_calls = []
|
|
51
|
+
self.closed = False
|
|
52
|
+
|
|
53
|
+
async def get(self, **kwargs):
|
|
54
|
+
self.get_calls.append(kwargs)
|
|
55
|
+
response = self.responses.pop(0)
|
|
56
|
+
if isinstance(response, Exception):
|
|
57
|
+
raise response
|
|
58
|
+
return response
|
|
59
|
+
|
|
60
|
+
def post(self, url, data=None):
|
|
61
|
+
self.post_calls.append((url, data))
|
|
62
|
+
response = self.responses.pop(0)
|
|
63
|
+
if isinstance(response, Exception):
|
|
64
|
+
raise response
|
|
65
|
+
return response
|
|
66
|
+
|
|
67
|
+
async def close(self):
|
|
68
|
+
self.closed = True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ValidationTests(unittest.TestCase):
|
|
72
|
+
def test_valid_and_invalid_player_ids(self):
|
|
73
|
+
Session._kwarg_check(playerid=PLAYER_ID)
|
|
74
|
+
for value in (PLAYER_ID + "junk", "bad", "", 123, None):
|
|
75
|
+
if value is None:
|
|
76
|
+
continue
|
|
77
|
+
with self.subTest(value=value), self.assertRaises(BadArgument):
|
|
78
|
+
Session._kwarg_check(playerid=value)
|
|
79
|
+
|
|
80
|
+
def test_supported_leaderboard_values(self):
|
|
81
|
+
for mapname in ("0.1", "0.2", "0.3", "0.4", 5.1):
|
|
82
|
+
Session._kwarg_check(mapname=mapname)
|
|
83
|
+
Session._kwarg_check(mode="score", difficulty="ENDLESS_I")
|
|
84
|
+
for kwargs in (
|
|
85
|
+
{"mapname": "0.5"},
|
|
86
|
+
{"mode": "time"},
|
|
87
|
+
{"difficulty": "HARD"},
|
|
88
|
+
):
|
|
89
|
+
with self.assertRaises(BadArgument):
|
|
90
|
+
Session._kwarg_check(**kwargs)
|
|
91
|
+
|
|
92
|
+
def test_public_constants_are_tuples(self):
|
|
93
|
+
self.assertEqual(infinitode.GAME_API_VERSION, 282)
|
|
94
|
+
self.assertIsInstance(infinitode.SUPPORTED_MAPS, tuple)
|
|
95
|
+
self.assertEqual(infinitode.SUPPORTED_MODES, ("score", "waves"))
|
|
96
|
+
self.assertEqual(
|
|
97
|
+
infinitode.SUPPORTED_DIFFICULTIES, ("EASY", "NORMAL", "ENDLESS_I")
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class SessionTests(unittest.IsolatedAsyncioTestCase):
|
|
102
|
+
async def test_player_by_id_uses_params_and_parses_profile(self):
|
|
103
|
+
client = FakeClient([FakeResponse(text=fixture("profile.html"))])
|
|
104
|
+
player = await Session(client).player(playerid=PLAYER_ID)
|
|
105
|
+
self.assertIsInstance(player, Player)
|
|
106
|
+
self.assertEqual(
|
|
107
|
+
(player.playerid, player.nickname, player.level),
|
|
108
|
+
(PLAYER_ID, "Test Player", 42),
|
|
109
|
+
)
|
|
110
|
+
self.assertEqual(player.score("0.1").score, 12345)
|
|
111
|
+
self.assertEqual(
|
|
112
|
+
client.get_calls[0],
|
|
113
|
+
{
|
|
114
|
+
"url": "https://infinitode.prineside.com/xdx/index.php",
|
|
115
|
+
"params": {"url": "profile/view", "id": PLAYER_ID},
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
async def test_exact_nickname_uses_encoded_request_params(self):
|
|
120
|
+
client = FakeClient([FakeResponse(text=fixture("profile.html"))])
|
|
121
|
+
await Session(client).player(nickname="A & B/雪")
|
|
122
|
+
self.assertEqual(
|
|
123
|
+
client.get_calls[0]["params"],
|
|
124
|
+
{"url": "profile/view", "nickname": "A & B/雪"},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
async def test_player_requires_exactly_one_valid_lookup(self):
|
|
128
|
+
session = Session(FakeClient([]))
|
|
129
|
+
for args in ({}, {"playerid": PLAYER_ID, "nickname": "Alpha"}):
|
|
130
|
+
with self.subTest(args=args), self.assertRaises(BadArgument):
|
|
131
|
+
await session.player(**args)
|
|
132
|
+
for nickname in ("", " ", 42):
|
|
133
|
+
with self.subTest(nickname=nickname), self.assertRaises(BadArgument):
|
|
134
|
+
await session.player(nickname=nickname)
|
|
135
|
+
|
|
136
|
+
async def test_player_not_found_and_changed_html_are_distinct(self):
|
|
137
|
+
missing = Session(
|
|
138
|
+
FakeClient([FakeResponse(text=fixture("player_not_found.html"))])
|
|
139
|
+
)
|
|
140
|
+
with self.assertRaises(PlayerNotFound):
|
|
141
|
+
await missing.player(nickname="Nobody")
|
|
142
|
+
changed = Session(
|
|
143
|
+
FakeClient([FakeResponse(text="<body><p>changed</p></body>")])
|
|
144
|
+
)
|
|
145
|
+
with self.assertRaises(ParseError):
|
|
146
|
+
await changed.player(nickname="Alpha")
|
|
147
|
+
|
|
148
|
+
async def test_search_parses_fields_limits_and_params(self):
|
|
149
|
+
client = FakeClient([FakeResponse(text=fixture("player_search.html"))])
|
|
150
|
+
results = await Session(client).search_players("mAtCh & 雪", limit=1)
|
|
151
|
+
self.assertEqual(len(results), 1)
|
|
152
|
+
self.assertEqual(
|
|
153
|
+
results[0], PlayerSummary(PLAYER_ID, "Alpha Match", 42, True)
|
|
154
|
+
)
|
|
155
|
+
self.assertEqual(
|
|
156
|
+
client.get_calls[0]["params"],
|
|
157
|
+
{"url": "profile/list", "nickname": "mAtCh & 雪"},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
async def test_search_empty_results_and_beta_route(self):
|
|
161
|
+
client = FakeClient([FakeResponse(text=fixture("player_search_empty.html"))])
|
|
162
|
+
self.assertEqual(await Session(client).search_players("none", beta=True), [])
|
|
163
|
+
self.assertTrue(client.get_calls[0]["url"].startswith("https://beta."))
|
|
164
|
+
|
|
165
|
+
async def test_search_validation_and_malformed_html(self):
|
|
166
|
+
session = Session(FakeClient([]))
|
|
167
|
+
for query in ("", " ", 2):
|
|
168
|
+
with self.subTest(query=query), self.assertRaises(BadArgument):
|
|
169
|
+
await session.search_players(query)
|
|
170
|
+
for limit in (0, 101, 1.5, True):
|
|
171
|
+
with self.subTest(limit=limit), self.assertRaises(BadArgument):
|
|
172
|
+
await session.search_players("a", limit=limit)
|
|
173
|
+
malformed = Session(
|
|
174
|
+
FakeClient(
|
|
175
|
+
[FakeResponse(text="<body><label>Players found: 1</label></body>")]
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
with self.assertRaises(ParseError):
|
|
179
|
+
await malformed.search_players("a")
|
|
180
|
+
|
|
181
|
+
async def test_standard_player_and_runtime_leaderboard_payloads(self):
|
|
182
|
+
payload = json.loads(fixture("leaderboard.json"))
|
|
183
|
+
clients = [
|
|
184
|
+
FakeClient([FakeResponse(payload=payload)]),
|
|
185
|
+
FakeClient([FakeResponse(payload=payload)]),
|
|
186
|
+
FakeClient([FakeResponse(payload=payload)]),
|
|
187
|
+
]
|
|
188
|
+
score = await Session(clients[0]).leaderboards_rank("0.1", PLAYER_ID)
|
|
189
|
+
board = await Session(clients[1]).leaderboards("0.2", playerid=PLAYER_ID)
|
|
190
|
+
runtime = await Session(clients[2]).runtime_leaderboards("0.3", PLAYER_ID)
|
|
191
|
+
self.assertEqual((score.score, len(board), len(runtime)), (321, 2, 2))
|
|
192
|
+
self.assertEqual(board.player.playerid, PLAYER_ID)
|
|
193
|
+
|
|
194
|
+
async def test_daily_quest_date_handling(self):
|
|
195
|
+
payload = json.loads(fixture("leaderboard.json"))
|
|
196
|
+
client = FakeClient([FakeResponse(payload=payload)])
|
|
197
|
+
board = await Session(client).daily_quest_leaderboards("2026-9-5")
|
|
198
|
+
self.assertEqual(board.date, "2026-09-05")
|
|
199
|
+
self.assertEqual(client.post_calls[0][1]["date"], "2026-09-05")
|
|
200
|
+
|
|
201
|
+
async def test_seasonal_parsing_and_malformed_html(self):
|
|
202
|
+
board = await Session(
|
|
203
|
+
FakeClient([FakeResponse(text=fixture("seasonal.html"))])
|
|
204
|
+
).seasonal_leaderboard()
|
|
205
|
+
self.assertEqual((board.season, board.total, len(board)), (21, 12345, 2))
|
|
206
|
+
malformed = Session(FakeClient([FakeResponse(text="<body>changed</body>")]))
|
|
207
|
+
with self.assertRaises(ParseError):
|
|
208
|
+
await malformed.seasonal_leaderboard()
|
|
209
|
+
|
|
210
|
+
async def test_api_error_for_http_json_and_error_payloads(self):
|
|
211
|
+
request_info = aiohttp.RequestInfo(
|
|
212
|
+
url=aiohttp.client_reqrep.URL("https://example.invalid"),
|
|
213
|
+
method="GET",
|
|
214
|
+
headers={},
|
|
215
|
+
real_url=aiohttp.client_reqrep.URL("https://example.invalid"),
|
|
216
|
+
)
|
|
217
|
+
status_error = aiohttp.ClientResponseError(request_info, (), status=500)
|
|
218
|
+
responses = (
|
|
219
|
+
FakeResponse(error=status_error),
|
|
220
|
+
FakeResponse(payload=ValueError("bad json")),
|
|
221
|
+
FakeResponse(payload={"status": "error", "message": "nope"}),
|
|
222
|
+
aiohttp.ClientConnectionError("offline"),
|
|
223
|
+
)
|
|
224
|
+
for response in responses:
|
|
225
|
+
with self.subTest(response=response), self.assertRaises(APIError):
|
|
226
|
+
await Session(FakeClient([response])).leaderboards("0.4")
|
|
227
|
+
|
|
228
|
+
with self.assertRaises(APIError):
|
|
229
|
+
await Session(
|
|
230
|
+
FakeClient([aiohttp.ClientConnectionError("offline")])
|
|
231
|
+
).search_players("alpha")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
if __name__ == "__main__":
|
|
235
|
+
unittest.main()
|
|
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
|