infinitode.py 1.0.4__tar.gz → 1.1.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.
Files changed (23) hide show
  1. {infinitode.py-1.0.4 → infinitode.py-1.1.0}/PKG-INFO +5 -2
  2. {infinitode.py-1.0.4 → infinitode.py-1.1.0}/README.md +4 -1
  3. {infinitode.py-1.0.4 → infinitode.py-1.1.0}/infinitode/__init__.py +2 -2
  4. infinitode.py-1.1.0/infinitode/badge.py +29 -0
  5. infinitode.py-1.1.0/infinitode/core.py +462 -0
  6. infinitode.py-1.1.0/infinitode/errors.py +25 -0
  7. {infinitode.py-1.0.4 → infinitode.py-1.1.0}/infinitode/leaderboard.py +63 -54
  8. {infinitode.py-1.0.4 → infinitode.py-1.1.0}/infinitode/player.py +89 -43
  9. {infinitode.py-1.0.4 → infinitode.py-1.1.0}/infinitode/score.py +47 -41
  10. infinitode.py-1.1.0/infinitode/utils.py +65 -0
  11. {infinitode.py-1.0.4 → infinitode.py-1.1.0}/infinitode.py.egg-info/PKG-INFO +5 -2
  12. infinitode.py-1.0.4/infinitode/badge.py +0 -31
  13. infinitode.py-1.0.4/infinitode/core.py +0 -315
  14. infinitode.py-1.0.4/infinitode/errors.py +0 -22
  15. infinitode.py-1.0.4/infinitode/utils.py +0 -36
  16. {infinitode.py-1.0.4 → infinitode.py-1.1.0}/LICENSE +0 -0
  17. {infinitode.py-1.0.4 → infinitode.py-1.1.0}/infinitode/py.typed +0 -0
  18. {infinitode.py-1.0.4 → infinitode.py-1.1.0}/infinitode.py.egg-info/SOURCES.txt +0 -0
  19. {infinitode.py-1.0.4 → infinitode.py-1.1.0}/infinitode.py.egg-info/dependency_links.txt +0 -0
  20. {infinitode.py-1.0.4 → infinitode.py-1.1.0}/infinitode.py.egg-info/requires.txt +0 -0
  21. {infinitode.py-1.0.4 → infinitode.py-1.1.0}/infinitode.py.egg-info/top_level.txt +0 -0
  22. {infinitode.py-1.0.4 → infinitode.py-1.1.0}/setup.cfg +0 -0
  23. {infinitode.py-1.0.4 → infinitode.py-1.1.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: infinitode.py
3
- Version: 1.0.4
3
+ Version: 1.1.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
@@ -116,7 +116,10 @@ async def main():
116
116
  player.nickname,
117
117
  player.level,
118
118
  player.xp,
119
- player.xp_max, # xp required per level (always 1000?)
119
+ player.xp_max, # xp required per level
120
+ player.season_level,
121
+ player.season_xp,
122
+ player.season_xp_max,
120
123
  player.total_score,
121
124
  player.total_rank,
122
125
  player.total_top,
@@ -105,7 +105,10 @@ async def main():
105
105
  player.nickname,
106
106
  player.level,
107
107
  player.xp,
108
- player.xp_max, # xp required per level (always 1000?)
108
+ player.xp_max, # xp required per level
109
+ player.season_level,
110
+ player.season_xp,
111
+ player.season_xp_max,
109
112
  player.total_score,
110
113
  player.total_rank,
111
114
  player.total_top,
@@ -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.0.4'
10
+ __version__ = "1.1.0"
11
11
  __license__ = """MIT License
12
12
 
13
- Copyright (c) 2022 Sprylos
13
+ Copyright (c) 2024 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
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ __all__ = ("Badge",)
4
+
5
+
6
+ class Badge:
7
+ """Represents an in-game pinned badge."""
8
+
9
+ __slots__ = ("icon_img", "icon_color", "overlay_img", "overlay_color")
10
+
11
+ def __init__(
12
+ self, iconImg: str, iconColor: str, overlayImg: str, overlayColor: str
13
+ ) -> None:
14
+ self.icon_img = iconImg
15
+ self.icon_color = iconColor
16
+ self.overlay_img = overlayImg
17
+ self.overlay_color = overlayColor
18
+
19
+ # magic methods
20
+
21
+ def __repr__(self) -> str:
22
+ attrs = {
23
+ "icon_img": self.icon_img,
24
+ "icon_color": self.icon_color,
25
+ "overlay_img": self.overlay_img,
26
+ "overlay_color": self.overlay_color,
27
+ }
28
+ inner = " ".join(f"{k}={v}" for k, v in attrs.items())
29
+ return f"<{self.__class__.__name__} {inner}>"
@@ -0,0 +1,462 @@
1
+ from __future__ import annotations
2
+
3
+ # std
4
+ import re
5
+ import asyncio
6
+ import logging
7
+ import datetime
8
+ from typing import (
9
+ Any,
10
+ Dict,
11
+ Optional,
12
+ Union,
13
+ )
14
+
15
+ # packages
16
+ import aiohttp
17
+ import bs4
18
+
19
+ # local
20
+ from .errors import APIError, BadArgument
21
+ from .leaderboard import Leaderboard
22
+ from .player import Player
23
+ from .score import Score
24
+ from .utils import async_expiring_cache, try_int
25
+
26
+
27
+ __all__ = ("Session",)
28
+
29
+ _log = logging.getLogger(__name__)
30
+
31
+ ID_REGEX = re.compile(r"U-([A-Z0-9]{4}-){2}[A-Z0-9]{6}")
32
+
33
+ # fmt: off
34
+ LEVELS = (
35
+ '1.1', '1.2', '1.3', '1.4', '1.5', '1.6', '1.7', '1.8', '1.b1',
36
+ '2.1', '2.2', '2.3', '2.4', '2.5', '2.6', '2.7', '2.8', '2.b1',
37
+ '3.1', '3.2', '3.3', '3.4', '3.5', '3.6', '3.7', '3.8', '3.b1',
38
+ '4.1', '4.2', '4.3', '4.4', '4.5', '4.6', '4.7', '4.8', '4.b1',
39
+ '5.1', '5.2', '5.3', '5.4', '5.5', '5.6', '5.7', '5.8', '5.b1', '5.b2',
40
+ '6.1', '6.2', '6.3', '6.4', '6.5', '6.6', 'rumble', 'dev', 'zecred',
41
+ 'DQ1', 'DQ3', 'DQ4', 'DQ5', 'DQ7', 'DQ8', 'DQ9', 'DQ10', 'DQ11', 'DQ12',
42
+ )
43
+ MODES = ('score', 'waves')
44
+ DIFFICULTIES = ('EASY', 'NORMAL', 'ENDLESS_I')
45
+ # fmt: on
46
+
47
+
48
+ def _base_url(beta: bool = False) -> str:
49
+ return f"https://{'beta.' if beta else ''}infinitode.prineside.com/"
50
+
51
+
52
+ class Session:
53
+ def __init__(self, session: Optional[aiohttp.ClientSession] = None) -> None:
54
+ self._session = session or aiohttp.ClientSession()
55
+
56
+ # async enter and exit allow for the fancy "with" statements
57
+ # useful so you don't have to close the session yourself
58
+ async def __aenter__(self):
59
+ return self
60
+
61
+ async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
62
+ await self.close()
63
+
64
+ async def close(self):
65
+ """Closes the internal ClientSession."""
66
+ await self._session.close()
67
+
68
+ # Rough parameter checking, "*" makes params keyword only
69
+ @staticmethod
70
+ def _kwarg_check(
71
+ *,
72
+ mapname: Optional[str] = None,
73
+ playerid: Optional[str] = None,
74
+ mode: Optional[str] = None,
75
+ difficulty: Optional[str] = None,
76
+ ) -> None:
77
+ 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:
84
+ raise BadArgument(
85
+ f"Invalid difficulty (must be one of {DIFFICULTIES}): " + difficulty
86
+ )
87
+
88
+ # not being more specific with the payload type
89
+ # so the typechecker stops annoying me
90
+ async def _post(
91
+ self, arg: str, data: Optional[Dict[str, Any]] = None, *, beta: bool = False
92
+ ) -> Dict[str, Any]:
93
+ """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"
95
+ _log.info("Sending POST request %s with data %s", arg, data)
96
+ async with self._session.post(url, data=data) as r:
97
+ try:
98
+ 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"]}')
109
+
110
+ @async_expiring_cache()
111
+ async def leaderboards_rank(
112
+ self,
113
+ mapname: Any,
114
+ playerid: str,
115
+ mode: str = "score",
116
+ difficulty: str = "NORMAL",
117
+ *,
118
+ beta: bool = False,
119
+ ) -> Score:
120
+ """
121
+ Retrieves a Score of the given player.
122
+ A valid playerid needs to be specified.
123
+ """
124
+ self._kwarg_check(
125
+ mapname=mapname, playerid=playerid, mode=mode, difficulty=difficulty
126
+ )
127
+ payload = await self._post(
128
+ "getLeaderboardsRank",
129
+ data={
130
+ "gamemode": "BASIC_LEVELS",
131
+ "difficulty": difficulty,
132
+ "playerid": playerid,
133
+ "mapname": str(mapname),
134
+ "mode": mode,
135
+ },
136
+ beta=beta,
137
+ )
138
+ return Score.from_payload(
139
+ "leaderboards_rank", mapname, mode, difficulty, playerid, payload
140
+ )
141
+
142
+ @async_expiring_cache()
143
+ async def leaderboards(
144
+ self,
145
+ mapname: Any,
146
+ playerid: Optional[str] = None,
147
+ mode: str = "score",
148
+ difficulty: str = "NORMAL",
149
+ *,
150
+ beta: bool = False,
151
+ ) -> Leaderboard:
152
+ """
153
+ Retrieves a Leaderboard.
154
+ The leaderboard contains the top 200 scores of the specified map.
155
+ """
156
+ self._kwarg_check(
157
+ mapname=mapname, playerid=playerid, mode=mode, difficulty=difficulty
158
+ )
159
+
160
+ payload = await self._post(
161
+ "getLeaderboards",
162
+ data={
163
+ "gamemode": "BASIC_LEVELS",
164
+ "difficulty": difficulty,
165
+ "playerid": playerid,
166
+ "mapname": str(mapname),
167
+ "mode": mode,
168
+ },
169
+ beta=beta,
170
+ )
171
+ lb = Leaderboard.from_payload(
172
+ "leaderboards", mapname, mode, difficulty, playerid, payload
173
+ )
174
+
175
+ return lb
176
+
177
+ @async_expiring_cache()
178
+ async def runtime_leaderboards(
179
+ self,
180
+ mapname: Any,
181
+ playerid: str,
182
+ mode: str = "score",
183
+ difficulty: str = "NORMAL",
184
+ *,
185
+ beta: bool = False,
186
+ ) -> Leaderboard:
187
+ """
188
+ Retrieves a Runtime Leaderboard (The one displayed top right in-game).
189
+ A valid playerid needs to be specified.
190
+ The leaderboard contains the top 200 scores and one Score for each top% of the specified map.
191
+ """
192
+ self._kwarg_check(
193
+ mapname=mapname, playerid=playerid, mode=mode, difficulty=difficulty
194
+ )
195
+ payload = await self._post(
196
+ "getRuntimeLeaderboards",
197
+ data={
198
+ "gamemode": "BASIC_LEVELS",
199
+ "difficulty": difficulty,
200
+ "playerid": playerid,
201
+ "mapname": str(mapname),
202
+ "mode": mode,
203
+ },
204
+ beta=beta,
205
+ )
206
+ return Leaderboard.from_payload(
207
+ "runtime_leaderboards", mapname, mode, difficulty, playerid, payload
208
+ )
209
+
210
+ @async_expiring_cache()
211
+ async def skill_point_leaderboard(
212
+ self, playerid: Optional[str] = None, *, beta: bool = False
213
+ ) -> Leaderboard:
214
+ """
215
+ Retrieves the Skill Point Leaderboard.
216
+ The leaderboard contains the top 3 skill point owners (looking at you, Eupho!).
217
+ """
218
+ if playerid is not None:
219
+ self._kwarg_check(playerid=playerid)
220
+
221
+ payload = await self._post(
222
+ "getSkillPointLeaderboard", data={"playerid": playerid}, beta=beta
223
+ )
224
+ lb = Leaderboard.from_payload(
225
+ "skill_point_leaderboard", "SP", "score", "NORMAL", playerid, payload
226
+ )
227
+
228
+ return lb
229
+
230
+ @async_expiring_cache()
231
+ async def daily_quest_leaderboards(
232
+ self,
233
+ date: Union[datetime.datetime, str, None] = None,
234
+ playerid: Optional[str] = None,
235
+ *,
236
+ beta: bool = False,
237
+ warning: bool = True,
238
+ ) -> Leaderboard:
239
+ """
240
+ Retrieves the Daily Quest Leaderboard for the given date.
241
+ If an invalid or no date is provided, the date will be set to the current date.
242
+ You may disable the invalid date warning by setting the warning param to False.
243
+ The leaderboard contains the top 200 DQ players of the given date.
244
+ """
245
+ if date is None:
246
+ date = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")
247
+ elif isinstance(date, datetime.datetime):
248
+ date = date.strftime("%Y-%m-%d")
249
+ else:
250
+ try:
251
+ datetime.datetime.strptime(date, "%Y-%m-%d")
252
+ except ValueError:
253
+ if warning is True:
254
+ _log.warning(
255
+ "Invalid date in daily_quest_leaderboards (Use YYYY-MM-DD format): %s",
256
+ date,
257
+ )
258
+ date = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")
259
+
260
+ if playerid is not None:
261
+ self._kwarg_check(playerid=playerid)
262
+
263
+ payload = await self._post(
264
+ "getDailyQuestLeaderboards",
265
+ data={"date": date, "playerid": playerid},
266
+ beta=beta,
267
+ )
268
+ lb = Leaderboard.from_payload(
269
+ "daily_quest_leaderboards",
270
+ "DQ",
271
+ "score",
272
+ "NORMAL",
273
+ playerid,
274
+ payload,
275
+ date=date,
276
+ )
277
+
278
+ return lb
279
+
280
+ @async_expiring_cache()
281
+ async def seasonal_leaderboard(self, beta: bool = False) -> Leaderboard:
282
+ """
283
+ Retrieves the season Leaderboard.
284
+ The leaderboard contains the top 100 scores in the season.
285
+ This coroutine never takes arguments.
286
+ """
287
+ url = _base_url(beta) + "xdx/?url=seasonal_leaderboard"
288
+ _log.info("Sending GET request to %s", url)
289
+
290
+ r = await self._session.get(url=url)
291
+ try:
292
+ r.raise_for_status()
293
+ except aiohttp.ClientResponseError:
294
+ raise APIError("Bad Gateway.")
295
+
296
+ seasonal = bs4.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
318
+
319
+ def _parse_player(self, content: str, playerid: str, beta: bool) -> Player:
320
+ data = bs4.BeautifulSoup(content, features="lxml")
321
+ t: Dict[str, Any] = {}
322
+ t["playerid"] = playerid
323
+ t["beta"] = beta
324
+ t["nickname"] = data.select_one("label:not([i18n])").text # type: ignore
325
+ totals = data.select_one('div[width="522"][height="140"][align="center"]')
326
+ if totals is None:
327
+ t.update({"total_score": 0, "total_rank": 0, "total_top": 0})
328
+ else:
329
+ totals = totals.select("label")
330
+ if len(totals) >= 4:
331
+ t["total_score"] = try_int(totals[1].text.replace(",", ""))
332
+ t["total_rank"] = try_int(totals[2].text.replace(",", ""))
333
+ t["total_top"] = totals[3].text.replace("- Top ", "")
334
+ else:
335
+ t.update({"total_score": 0, "total_rank": 0, "total_top": "0%"})
336
+ comments = data.findAll(text=lambda text: isinstance(text, bs4.Comment))
337
+ for x in comments:
338
+ if "Level:" in x:
339
+ t["level"] = int(x.split(">")[3].split("<")[0])
340
+ break
341
+ else:
342
+ t["level"] = 1
343
+ xp_data = data.select_one('div[width="330"][height="64"]')
344
+ if xp_data is None:
345
+ raise BadArgument("Invalid playerid: " + playerid)
346
+ xp_data = xp_data.select_one("label").text.split(" / ") # type: ignore
347
+ t["xp"] = int(xp_data[0])
348
+ t["xp_max"] = int(xp_data[1])
349
+ season_xp_data = data.select_one(
350
+ 'div[width="530"][align="center"][height="64"][pad-bottom="10"]'
351
+ )
352
+ if season_xp_data is None:
353
+ t["season_xp"] = 0
354
+ t["season_xp_max"] = 500
355
+ t["season_level"] = 1
356
+ else:
357
+ season_xp_borders = season_xp_data.select_one("label").text.split(" / ") # type: ignore
358
+ t["season_xp"] = int(season_xp_borders[0])
359
+ t["season_xp_max"] = int(season_xp_borders[1])
360
+ season_level_data = season_xp_data.select_one(
361
+ 'div[x="466"][width="64"][height="64"]'
362
+ )
363
+ if season_level_data is None:
364
+ t["season_level"] = 1
365
+ else:
366
+ t["season_level"] = int(season_level_data["data"].split(":")[1]) # type: ignore
367
+
368
+ t["levels"] = {}
369
+ for x in data.select('div[width="800"][height="40"]')[1:]:
370
+ level_data = x.select("label")
371
+ level = level_data[0].text
372
+ if not x.select_one('label[i18n="not_ranked"]'):
373
+ rank = int(level_data[2].text.replace(",", ""))
374
+ score = int(level_data[1].text.replace(",", ""))
375
+ total = int(level_data[3].text.replace("/ ", "").replace(",", ""))
376
+ top = level_data[-1].text
377
+ else:
378
+ rank, score, total, top = 0, 0, 0, "-%"
379
+ t["levels"][level] = Score(
380
+ "player",
381
+ level,
382
+ "score",
383
+ "NORMAL",
384
+ playerid,
385
+ rank=rank,
386
+ score=score,
387
+ total=total,
388
+ top=top,
389
+ level=t["level"],
390
+ nickname=t["nickname"],
391
+ )
392
+ t["badges"] = {}
393
+ icos = [
394
+ "daily-game",
395
+ "invited-players",
396
+ "killed-enemies",
397
+ "mined-resources",
398
+ "skillful",
399
+ "of-merit",
400
+ "beta-tester-season-2",
401
+ f"high-leveled-{t['level'] // 10 if t['level'] < 100 else 10}",
402
+ ]
403
+ for x in data.select('div[width="80"][height="80"]'):
404
+ rar: str = x.select("img")[0]["src"].split("bg-")[1] # type: ignore
405
+ if rar in [
406
+ "not-received",
407
+ "common",
408
+ "rare",
409
+ "very-rare",
410
+ "epic",
411
+ "legendary",
412
+ "supreme",
413
+ "artifact",
414
+ ]:
415
+ ico: str = x.select("img")[1]["src"].split("icon-")[1] # type: ignore
416
+ if (
417
+ ico
418
+ in icos
419
+ + [
420
+ "youtube-author-" + rar,
421
+ f"season-level-{rar}-2",
422
+ ]
423
+ or ico[:8] == "season-1"
424
+ ):
425
+ col: str = x.select("img")[-1]["color"] # type: ignore
426
+ t["badges"][ico] = (rar, col)
427
+ labels = data.select('table[width="800"][align="center"]')[-1].select("label")
428
+ replays = labels[-3].string.split(" ") # type: ignore
429
+ t["replays"] = 0 if len(replays) != 4 else int(replays[3])
430
+ issues = labels[-2].string.split(" ") # type: ignore
431
+ t["issues"] = 0 if len(issues) != 6 else int(issues[0][3:])
432
+ sp = list(labels[-1].string.split("ned ")[1].split(" ")) # type: ignore
433
+ day = sp[0][:-2] if len(sp[0][:2]) == 2 else "0" + sp[0][:2]
434
+ t["created_at"] = datetime.datetime.strptime(
435
+ day + " " + sp[-2] + " " + sp[-1], "%d %B %Y"
436
+ ).strftime("%Y-%m-%d")
437
+
438
+ return Player(**t)
439
+
440
+ @async_expiring_cache()
441
+ async def player(self, playerid: str, beta: bool = False) -> Player:
442
+ """
443
+ Retrieves the Player.
444
+ A valid playerid needs to be specified.
445
+ """
446
+ self._kwarg_check(playerid=playerid)
447
+ url = _base_url(beta) + "xdx/index.php?url=profile/view&id=" + playerid
448
+ _log.info("Sending GET request to %s", url)
449
+
450
+ r = await self._session.get(url=url)
451
+ try:
452
+ r.raise_for_status()
453
+ except aiohttp.ClientResponseError:
454
+ raise APIError("Bad Gateway.")
455
+
456
+ loop = asyncio.get_event_loop()
457
+ try:
458
+ return await loop.run_in_executor(
459
+ None, self._parse_player, await r.text(), playerid, beta
460
+ )
461
+ except Exception as exc:
462
+ raise BadArgument("Invalid playerid: " + playerid) from exc
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ __all__ = (
4
+ "InfinitodeError",
5
+ "APIError",
6
+ "BadArgument",
7
+ )
8
+
9
+
10
+ class InfinitodeError(Exception):
11
+ """Base Infinitode Error."""
12
+
13
+ pass
14
+
15
+
16
+ class APIError(InfinitodeError):
17
+ """Error directly related to the communication with the API."""
18
+
19
+ pass
20
+
21
+
22
+ class BadArgument(InfinitodeError):
23
+ """Error raised when an invalid argument is passed."""
24
+
25
+ pass