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.
@@ -1,12 +1,21 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: infinitode.py
3
- Version: 1.1.5
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.1.5"
10
+ __version__ = "1.2.0"
11
11
  __license__ = """MIT License
12
12
 
13
- Copyright (c) 2025 Sprylos
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__ = ("Session",)
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
- LEVELS = (
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
- MODES = ('score', 'waves')
44
- DIFFICULTIES = ('EASY', 'NORMAL', 'ENDLESS_I')
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.__session = session or aiohttp.ClientSession()
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.__session.close()
78
+ await self._session.close()
67
79
 
68
- # Rough parameter checking, "*" makes params keyword only
69
80
  @staticmethod
70
- def __kwarg_check(
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: " + mapname)
79
- if playerid is not None and not ID_REGEX.match(playerid):
80
- raise BadArgument("Invalid playerid: " + playerid)
81
- if mode is not None and not mode in MODES:
82
- raise BadArgument(f"Invalid mode (must be one of {MODES}): " + mode)
83
- if difficulty is not None and not difficulty in DIFFICULTIES:
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}): " + difficulty
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 __post(
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) + f"?m=api&a={arg}&apiv=1&g=com.prineside.tdi2&v=282"
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
- async with self.__session.post(url, data=data) as r:
97
- try:
111
+ try:
112
+ async with self._session.post(url, data=data) as r:
98
113
  r.raise_for_status()
99
- except aiohttp.ClientResponseError:
100
- raise APIError("Something went wrong. Try again later")
101
-
102
- payload: Dict[str, Any] = await r.json()
103
- LOG.debug("Response to POST request %s: %s", arg, payload)
104
-
105
- if payload["status"] == "success":
106
- return payload
107
- else:
108
- raise APIError(f'Error response from server: {payload["message"]}')
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.__kwarg_check(
145
+ self._kwarg_check(
125
146
  mapname=mapname, playerid=playerid, mode=mode, difficulty=difficulty
126
147
  )
127
- payload = await self.__post(
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.__kwarg_check(
177
+ self._kwarg_check(
157
178
  mapname=mapname, playerid=playerid, mode=mode, difficulty=difficulty
158
179
  )
159
180
 
160
- payload = await self.__post(
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.__kwarg_check(
213
+ self._kwarg_check(
193
214
  mapname=mapname, playerid=playerid, mode=mode, difficulty=difficulty
194
215
  )
195
- payload = await self.__post(
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.__kwarg_check(playerid=playerid)
240
+ self._kwarg_check(playerid=playerid)
220
241
 
221
- payload = await self.__post(
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.__kwarg_check(playerid=playerid)
284
+ self._kwarg_check(playerid=playerid)
262
285
 
263
- payload = await self.__post(
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 100 scores in the season.
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
- except aiohttp.ClientResponseError:
294
- raise APIError("Bad Gateway.")
316
+ content = await r.text()
317
+ except aiohttp.ClientError as exc:
318
+ raise APIError("Bad Gateway.") from exc
295
319
 
296
- seasonal = BeautifulSoup(await r.text(), features="lxml")
297
-
298
- # fmt: off
299
- season = int(seasonal.select_one('label[i18n="season_formatted"]')['i18nf'].replace('["', '').replace('"]', '')) # type: ignore
300
- player_count = int(seasonal.select('label[i18n="player_count_formatted"]')[
301
- 0]['i18nf'].replace('["', '').replace('"]', '').replace(',', '')) # type: ignore
302
- lb = Leaderboard.from_payload(
303
- 'seasonal_leaderboard', 'season', 'score', 'NORMAL', None, {
304
- 'status': 'success',
305
- 'player': {'total': player_count},
306
- 'leaderboards': [
307
- {
308
- 'playerid': seasonal.select('label[color="LIGHT_BLUE:P300"]')[x]['click'].split('id=')[1], # type: ignore
309
- 'nickname': seasonal.select('label[color="LIGHT_BLUE:P300"]')[x].text,
310
- 'score': seasonal.select('label[nowrap="true"][text-align="right"]')[x].text.replace(',', '')
311
- } for x in range(len(seasonal.select('div[x="90"]')))
312
- ]
313
- }, season=season
314
- )
315
- # fmt: on
316
-
317
- return lb
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 the Player.
329
- A valid playerid needs to be specified.
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 and nickname:
332
- raise BadArgument("You can't specify both playerid and nickname.")
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
- url = url + "xdx/index.php?url=profile/view&nickname=" + nickname
337
- elif playerid:
338
- self.__kwarg_check(playerid=playerid)
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
- raise BadArgument("You need to specify either playerid or nickname.")
386
+ self._kwarg_check(playerid=playerid)
387
+ params = {"url": "profile/view", "id": playerid}
342
388
 
343
- LOG.info("Sending GET request to %s", url)
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
- except aiohttp.ClientResponseError:
349
- raise APIError("Bad Gateway.")
350
-
351
- loop = asyncio.get_event_loop()
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.__parse_player, await r.text(), beta
408
+ None, self._parse_player, content, beta
355
409
  )
356
410
  except Exception as exc:
357
- raise BadArgument(
358
- f"Invalid playerid/nickname: {playerid or nickname}"
359
- ) from exc
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 __parse_player(cls, content: str, beta: bool) -> Player:
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.__parse_totals(data, t)
371
- cls.__parse_level_from_comments(data, t)
372
- cls.__parse_xp_data(data, t)
373
- cls.__parse_levels(data, t)
374
- cls.__parse_badges(data, t)
375
- cls.__parse_misc(data, t)
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 __parse_totals(data: BeautifulSoup, t: Dict[str, Any]) -> None:
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 __parse_level_from_comments(data: BeautifulSoup, t: Dict[str, Any]) -> None:
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 __parse_xp_data(data: BeautifulSoup, t: Dict[str, Any]) -> None:
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 __parse_levels(data: BeautifulSoup, t: Dict[str, Any]) -> None:
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 __parse_badges(data: BeautifulSoup, t: Dict[str, Any]) -> None:
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 __parse_misc(data: BeautifulSoup, t: Dict[str, Any]) -> None:
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
- """Returns the player's daily quest score, or raises InfinitodeError if it wasn't fetched yet."""
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
@@ -13,4 +13,5 @@ infinitode.py.egg-info/PKG-INFO
13
13
  infinitode.py.egg-info/SOURCES.txt
14
14
  infinitode.py.egg-info/dependency_links.txt
15
15
  infinitode.py.egg-info/requires.txt
16
- infinitode.py.egg-info/top_level.txt
16
+ infinitode.py.egg-info/top_level.txt
17
+ tests/test_core.py
@@ -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