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.
@@ -1,12 +1,21 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: infinitode.py
3
- Version: 1.1.6
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.1.6"
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/"
@@ -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: " + mapname)
78
- if playerid is not None and not ID_REGEX.match(playerid):
79
- raise BadArgument("Invalid playerid: " + playerid)
80
- if mode is not None and not mode in MODES:
81
- raise BadArgument(f"Invalid mode (must be one of {MODES}): " + mode)
82
- 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:
83
97
  raise BadArgument(
84
- f"Invalid difficulty (must be one of {DIFFICULTIES}): " + difficulty
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) + 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
+ )
94
110
  LOG.info("Sending POST request %s with data %s", arg, data)
95
- async with self._session.post(url, data=data) as r:
96
- try:
111
+ try:
112
+ async with self._session.post(url, data=data) as r:
97
113
  r.raise_for_status()
98
- except aiohttp.ClientResponseError:
99
- raise APIError("Something went wrong. Try again later")
100
-
101
- payload: Dict[str, Any] = await r.json()
102
- LOG.debug("Response to POST request %s: %s", arg, payload)
103
-
104
- if payload["status"] == "success":
105
- return payload
106
- else:
107
- 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
+ )
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 100 scores in the season.
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
- except aiohttp.ClientResponseError:
295
- raise APIError("Bad Gateway.")
316
+ content = await r.text()
317
+ except aiohttp.ClientError as exc:
318
+ raise APIError("Bad Gateway.") from exc
296
319
 
297
- seasonal = BeautifulSoup(await r.text(), features="lxml")
298
-
299
- # fmt: off
300
- season = int(seasonal.select_one('label[i18n="season_formatted"]')['i18nf'].replace('["', '').replace('"]', '')) # type: ignore
301
- player_count = int(seasonal.select('label[i18n="player_count_formatted"]')[
302
- 0]['i18nf'].replace('["', '').replace('"]', '').replace(',', '')) # type: ignore
303
- lb = Leaderboard.from_payload(
304
- 'seasonal_leaderboard', 'season', 'score', 'NORMAL', None, {
305
- 'status': 'success',
306
- 'player': {'total': player_count},
307
- 'leaderboards': [
308
- {
309
- 'playerid': seasonal.select('label[color="LIGHT_BLUE:P300"]')[x]['click'].split('id=')[1], # type: ignore
310
- 'nickname': seasonal.select('label[color="LIGHT_BLUE:P300"]')[x].text,
311
- 'score': seasonal.select('label[nowrap="true"][text-align="right"]')[x].text.replace(',', '')
312
- } for x in range(len(seasonal.select('div[x="90"]')))
313
- ]
314
- }, season=season
315
- )
316
- # fmt: on
317
-
318
- 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
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 the Player.
330
- 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.
331
376
  """
332
- if playerid and nickname:
333
- 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.")
334
379
 
335
380
  url = base_url(beta)
336
- if nickname:
337
- url = url + "xdx/index.php?url=profile/view&nickname=" + nickname
338
- elif playerid:
339
- self._kwarg_check(playerid=playerid)
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
- raise BadArgument("You need to specify either playerid or nickname.")
386
+ self._kwarg_check(playerid=playerid)
387
+ params = {"url": "profile/view", "id": playerid}
343
388
 
344
- 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)
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
- except aiohttp.ClientResponseError:
350
- raise APIError("Bad Gateway.")
351
-
352
- 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()
353
406
  try:
354
407
  return await loop.run_in_executor(
355
- None, self._parse_player, await r.text(), beta
408
+ None, self._parse_player, content, beta
356
409
  )
357
410
  except Exception as exc:
358
- raise BadArgument(
359
- f"Invalid playerid/nickname: {playerid or nickname}"
360
- ) 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
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
- """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