infinitode.py 1.1.5__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.5/infinitode.py.egg-info → infinitode_py-1.2.0}/PKG-INFO +43 -6
- infinitode.py-1.1.5/PKG-INFO → infinitode_py-1.2.0/README.md +31 -13
- {infinitode.py-1.1.5 → infinitode_py-1.2.0}/infinitode/__init__.py +2 -2
- {infinitode.py-1.1.5 → infinitode_py-1.2.0}/infinitode/core.py +228 -101
- {infinitode.py-1.1.5 → infinitode_py-1.2.0}/infinitode/errors.py +14 -0
- {infinitode.py-1.1.5 → infinitode_py-1.2.0}/infinitode/player.py +13 -2
- infinitode.py-1.1.5/README.md → infinitode_py-1.2.0/infinitode.py.egg-info/PKG-INFO +50 -1
- {infinitode.py-1.1.5 → 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.5 → infinitode_py-1.2.0}/infinitode/badge.py +0 -0
- {infinitode.py-1.1.5 → infinitode_py-1.2.0}/infinitode/leaderboard.py +0 -0
- {infinitode.py-1.1.5 → infinitode_py-1.2.0}/infinitode/py.typed +0 -0
- {infinitode.py-1.1.5 → infinitode_py-1.2.0}/infinitode/score.py +0 -0
- {infinitode.py-1.1.5 → infinitode_py-1.2.0}/infinitode/utils.py +0 -0
- {infinitode.py-1.1.5 → infinitode_py-1.2.0}/infinitode.py.egg-info/dependency_links.txt +0 -0
- {infinitode.py-1.1.5 → infinitode_py-1.2.0}/infinitode.py.egg-info/requires.txt +0 -0
- {infinitode.py-1.1.5 → infinitode_py-1.2.0}/infinitode.py.egg-info/top_level.txt +0 -0
- {infinitode.py-1.1.5 → infinitode_py-1.2.0}/setup.cfg +0 -0
- {infinitode.py-1.1.5 → 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.5
|
|
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/"
|
|
@@ -51,7 +63,7 @@ def base_url(beta: bool = False) -> str:
|
|
|
51
63
|
|
|
52
64
|
class Session:
|
|
53
65
|
def __init__(self, session: Optional[aiohttp.ClientSession] = None) -> None:
|
|
54
|
-
self.
|
|
66
|
+
self._session = session or aiohttp.ClientSession()
|
|
55
67
|
|
|
56
68
|
# async enter and exit allow for the fancy "with" statements
|
|
57
69
|
# useful so you don't have to close the session yourself
|
|
@@ -63,11 +75,10 @@ class Session:
|
|
|
63
75
|
|
|
64
76
|
async def close(self):
|
|
65
77
|
"""Closes the internal ClientSession."""
|
|
66
|
-
await self.
|
|
78
|
+
await self._session.close()
|
|
67
79
|
|
|
68
|
-
# Rough parameter checking, "*" makes params keyword only
|
|
69
80
|
@staticmethod
|
|
70
|
-
def
|
|
81
|
+
def _kwarg_check(
|
|
71
82
|
*,
|
|
72
83
|
mapname: Optional[str] = None,
|
|
73
84
|
playerid: Optional[str] = None,
|
|
@@ -75,37 +86,47 @@ class Session:
|
|
|
75
86
|
difficulty: Optional[str] = None,
|
|
76
87
|
) -> None:
|
|
77
88
|
if mapname is not None and str(mapname) not in LEVELS:
|
|
78
|
-
raise BadArgument("Invalid map: "
|
|
79
|
-
if playerid is not None and
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
raise BadArgument(f"Invalid
|
|
83
|
-
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:
|
|
84
97
|
raise BadArgument(
|
|
85
|
-
f"Invalid difficulty (must be one of {DIFFICULTIES}): "
|
|
98
|
+
f"Invalid difficulty (must be one of {DIFFICULTIES}): {difficulty}"
|
|
86
99
|
)
|
|
87
100
|
|
|
88
101
|
# not being more specific with the payload type
|
|
89
102
|
# so the typechecker stops annoying me
|
|
90
|
-
async def
|
|
103
|
+
async def _post(
|
|
91
104
|
self, arg: str, data: Optional[Dict[str, Any]] = None, *, beta: bool = False
|
|
92
105
|
) -> Dict[str, Any]:
|
|
93
106
|
"""Internal post method to communicate with Rainy's API"""
|
|
94
|
-
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
|
+
)
|
|
95
110
|
LOG.info("Sending POST request %s with data %s", arg, data)
|
|
96
|
-
|
|
97
|
-
|
|
111
|
+
try:
|
|
112
|
+
async with self._session.post(url, data=data) as r:
|
|
98
113
|
r.raise_for_status()
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
+
)
|
|
109
130
|
|
|
110
131
|
@async_expiring_cache()
|
|
111
132
|
async def leaderboards_rank(
|
|
@@ -121,10 +142,10 @@ class Session:
|
|
|
121
142
|
Retrieves a Score of the given player.
|
|
122
143
|
A valid playerid needs to be specified.
|
|
123
144
|
"""
|
|
124
|
-
self.
|
|
145
|
+
self._kwarg_check(
|
|
125
146
|
mapname=mapname, playerid=playerid, mode=mode, difficulty=difficulty
|
|
126
147
|
)
|
|
127
|
-
payload = await self.
|
|
148
|
+
payload = await self._post(
|
|
128
149
|
"getLeaderboardsRank",
|
|
129
150
|
data={
|
|
130
151
|
"gamemode": "BASIC_LEVELS",
|
|
@@ -153,11 +174,11 @@ class Session:
|
|
|
153
174
|
Retrieves a Leaderboard.
|
|
154
175
|
The leaderboard contains the top 200 scores of the specified map.
|
|
155
176
|
"""
|
|
156
|
-
self.
|
|
177
|
+
self._kwarg_check(
|
|
157
178
|
mapname=mapname, playerid=playerid, mode=mode, difficulty=difficulty
|
|
158
179
|
)
|
|
159
180
|
|
|
160
|
-
payload = await self.
|
|
181
|
+
payload = await self._post(
|
|
161
182
|
"getLeaderboards",
|
|
162
183
|
data={
|
|
163
184
|
"gamemode": "BASIC_LEVELS",
|
|
@@ -189,10 +210,10 @@ class Session:
|
|
|
189
210
|
A valid playerid needs to be specified.
|
|
190
211
|
The leaderboard contains the top 200 scores and one Score for each top% of the specified map.
|
|
191
212
|
"""
|
|
192
|
-
self.
|
|
213
|
+
self._kwarg_check(
|
|
193
214
|
mapname=mapname, playerid=playerid, mode=mode, difficulty=difficulty
|
|
194
215
|
)
|
|
195
|
-
payload = await self.
|
|
216
|
+
payload = await self._post(
|
|
196
217
|
"getRuntimeLeaderboards",
|
|
197
218
|
data={
|
|
198
219
|
"gamemode": "BASIC_LEVELS",
|
|
@@ -216,9 +237,9 @@ class Session:
|
|
|
216
237
|
The leaderboard contains the top 3 skill point owners (looking at you, Eupho!).
|
|
217
238
|
"""
|
|
218
239
|
if playerid is not None:
|
|
219
|
-
self.
|
|
240
|
+
self._kwarg_check(playerid=playerid)
|
|
220
241
|
|
|
221
|
-
payload = await self.
|
|
242
|
+
payload = await self._post(
|
|
222
243
|
"getSkillPointLeaderboard", data={"playerid": playerid}, beta=beta
|
|
223
244
|
)
|
|
224
245
|
lb = Leaderboard.from_payload(
|
|
@@ -248,7 +269,7 @@ class Session:
|
|
|
248
269
|
date = date.strftime("%Y-%m-%d")
|
|
249
270
|
else:
|
|
250
271
|
try:
|
|
251
|
-
datetime.datetime.strptime(date, "%Y-%m-%d")
|
|
272
|
+
date_obj = datetime.datetime.strptime(date, "%Y-%m-%d")
|
|
252
273
|
except ValueError:
|
|
253
274
|
if warning is True:
|
|
254
275
|
LOG.warning(
|
|
@@ -256,11 +277,13 @@ class Session:
|
|
|
256
277
|
date,
|
|
257
278
|
)
|
|
258
279
|
date = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")
|
|
280
|
+
else:
|
|
281
|
+
date = date_obj.strftime("%Y-%m-%d") # allows for missing leading zeros
|
|
259
282
|
|
|
260
283
|
if playerid is not None:
|
|
261
|
-
self.
|
|
284
|
+
self._kwarg_check(playerid=playerid)
|
|
262
285
|
|
|
263
|
-
payload = await self.
|
|
286
|
+
payload = await self._post(
|
|
264
287
|
"getDailyQuestLeaderboards",
|
|
265
288
|
data={"date": date, "playerid": playerid},
|
|
266
289
|
beta=beta,
|
|
@@ -281,40 +304,62 @@ class Session:
|
|
|
281
304
|
async def seasonal_leaderboard(self, *, beta: bool = False) -> Leaderboard:
|
|
282
305
|
"""
|
|
283
306
|
Retrieves the season Leaderboard.
|
|
284
|
-
The leaderboard contains the top
|
|
307
|
+
The leaderboard contains the top 200 scores in the season.
|
|
285
308
|
This coroutine never takes arguments.
|
|
286
309
|
"""
|
|
287
310
|
url = base_url(beta) + "xdx/?url=seasonal_leaderboard"
|
|
288
311
|
LOG.info("Sending GET request to %s", url)
|
|
289
312
|
|
|
290
|
-
r = await self.__session.get(url=url)
|
|
291
313
|
try:
|
|
314
|
+
r = await self._session.get(url=url)
|
|
292
315
|
r.raise_for_status()
|
|
293
|
-
|
|
294
|
-
|
|
316
|
+
content = await r.text()
|
|
317
|
+
except aiohttp.ClientError as exc:
|
|
318
|
+
raise APIError("Bad Gateway.") from exc
|
|
295
319
|
|
|
296
|
-
seasonal = BeautifulSoup(
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
318
363
|
|
|
319
364
|
@async_expiring_cache()
|
|
320
365
|
async def player(
|
|
@@ -325,41 +370,123 @@ class Session:
|
|
|
325
370
|
beta: bool = False,
|
|
326
371
|
) -> Player:
|
|
327
372
|
"""
|
|
328
|
-
Retrieves
|
|
329
|
-
|
|
373
|
+
Retrieves a Player by exact player ID or nickname.
|
|
374
|
+
|
|
375
|
+
Nickname matching is case-insensitive. Exactly one lookup value is required.
|
|
330
376
|
"""
|
|
331
|
-
if playerid
|
|
332
|
-
raise BadArgument("
|
|
377
|
+
if (playerid is None) == (nickname is None):
|
|
378
|
+
raise BadArgument("Specify exactly one of playerid or nickname.")
|
|
333
379
|
|
|
334
380
|
url = base_url(beta)
|
|
335
|
-
if nickname:
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
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}
|
|
340
385
|
else:
|
|
341
|
-
|
|
386
|
+
self._kwarg_check(playerid=playerid)
|
|
387
|
+
params = {"url": "profile/view", "id": playerid}
|
|
342
388
|
|
|
343
|
-
|
|
389
|
+
url += "xdx/index.php"
|
|
390
|
+
LOG.info("Sending GET request to %s with params %s", url, params)
|
|
344
391
|
|
|
345
|
-
r = await self.__session.get(url=url)
|
|
346
392
|
try:
|
|
393
|
+
r = await self._session.get(url=url, params=params)
|
|
347
394
|
r.raise_for_status()
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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()
|
|
352
406
|
try:
|
|
353
407
|
return await loop.run_in_executor(
|
|
354
|
-
None, self.
|
|
408
|
+
None, self._parse_player, content, beta
|
|
355
409
|
)
|
|
356
410
|
except Exception as exc:
|
|
357
|
-
raise
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
360
487
|
|
|
361
488
|
@classmethod
|
|
362
|
-
def
|
|
489
|
+
def _parse_player(cls, content: str, beta: bool) -> Player:
|
|
363
490
|
data = BeautifulSoup(content, features="lxml")
|
|
364
491
|
|
|
365
492
|
t: Dict[str, Any] = {}
|
|
@@ -367,17 +494,17 @@ class Session:
|
|
|
367
494
|
t["playerid"] = data.select_one("label:not([i18n],[font-min-size])").text # type: ignore
|
|
368
495
|
t["nickname"] = data.select_one("label:not([i18n])").text # type: ignore
|
|
369
496
|
|
|
370
|
-
cls.
|
|
371
|
-
cls.
|
|
372
|
-
cls.
|
|
373
|
-
cls.
|
|
374
|
-
cls.
|
|
375
|
-
cls.
|
|
497
|
+
cls._parse_totals(data, t)
|
|
498
|
+
cls._parse_level_from_comments(data, t)
|
|
499
|
+
cls._parse_xp_data(data, t)
|
|
500
|
+
cls._parse_levels(data, t)
|
|
501
|
+
cls._parse_badges(data, t)
|
|
502
|
+
cls._parse_misc(data, t)
|
|
376
503
|
|
|
377
504
|
return Player(**t)
|
|
378
505
|
|
|
379
506
|
@staticmethod
|
|
380
|
-
def
|
|
507
|
+
def _parse_totals(data: BeautifulSoup, t: Dict[str, Any]) -> None:
|
|
381
508
|
totals = data.select_one('div[width="522"][height="140"][align="center"]')
|
|
382
509
|
if totals is None:
|
|
383
510
|
t.update({"total_score": 0, "total_rank": 0, "total_top": 0})
|
|
@@ -392,7 +519,7 @@ class Session:
|
|
|
392
519
|
t.update({"total_score": 0, "total_rank": 0, "total_top": "0%"})
|
|
393
520
|
|
|
394
521
|
@staticmethod
|
|
395
|
-
def
|
|
522
|
+
def _parse_level_from_comments(data: BeautifulSoup, t: Dict[str, Any]) -> None:
|
|
396
523
|
comments = data.findAll(text=lambda text: isinstance(text, Comment))
|
|
397
524
|
for x in comments:
|
|
398
525
|
if "Level:" in x:
|
|
@@ -402,7 +529,7 @@ class Session:
|
|
|
402
529
|
t["level"] = 1
|
|
403
530
|
|
|
404
531
|
@staticmethod
|
|
405
|
-
def
|
|
532
|
+
def _parse_xp_data(data: BeautifulSoup, t: Dict[str, Any]) -> None:
|
|
406
533
|
xp_data = data.select_one('div[width="330"][height="64"]')
|
|
407
534
|
xp_data = xp_data.select_one("label").text.split(" / ") # type: ignore
|
|
408
535
|
t["xp"] = int(xp_data[0])
|
|
@@ -427,7 +554,7 @@ class Session:
|
|
|
427
554
|
t["season_level"] = int(season_level_data["data"].split(":")[1]) # type: ignore
|
|
428
555
|
|
|
429
556
|
@staticmethod
|
|
430
|
-
def
|
|
557
|
+
def _parse_levels(data: BeautifulSoup, t: Dict[str, Any]) -> None:
|
|
431
558
|
t["levels"] = {}
|
|
432
559
|
|
|
433
560
|
for x in data.select('div[width="800"][height="40"]')[1:]:
|
|
@@ -455,7 +582,7 @@ class Session:
|
|
|
455
582
|
)
|
|
456
583
|
|
|
457
584
|
@staticmethod
|
|
458
|
-
def
|
|
585
|
+
def _parse_badges(data: BeautifulSoup, t: Dict[str, Any]) -> None:
|
|
459
586
|
t["badges"] = {}
|
|
460
587
|
|
|
461
588
|
icos = [
|
|
@@ -492,7 +619,7 @@ class Session:
|
|
|
492
619
|
t["badges"][ico] = (rar, col)
|
|
493
620
|
|
|
494
621
|
@staticmethod
|
|
495
|
-
def
|
|
622
|
+
def _parse_misc(data: BeautifulSoup, t: Dict[str, Any]) -> None:
|
|
496
623
|
labels = data.select('table[width="800"][align="center"]')[-1].select("label")
|
|
497
624
|
replays = labels[-3].string.split(" ") # type: ignore
|
|
498
625
|
t["replays"] = 0 if len(replays) != 4 else int(replays[3])
|
|
@@ -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
|