noxaeapi-sdk 0.4.0__py3-none-any.whl

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.
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ import urllib.parse
4
+ from typing import Any, Dict, List
5
+
6
+ from ..http_engine import HttpEngine
7
+ from ..types import Weather, World
8
+
9
+
10
+ def _q(value: str) -> str:
11
+ return urllib.parse.quote(value, safe="")
12
+
13
+
14
+ class WorldsModule:
15
+ def __init__(self, http: HttpEngine) -> None:
16
+ self._http = http
17
+
18
+ def list(self) -> List[World]:
19
+ """List all worlds."""
20
+ return self._http.request("GET", "worlds")
21
+
22
+ def save_all(self) -> None:
23
+ """Save all worlds to disk."""
24
+ return self._http.request("POST", "worlds/save")
25
+
26
+ def download_all(self) -> Dict[str, str]:
27
+ """Get a download link/stream reference for all worlds."""
28
+ return self._http.request("GET", "worlds/download")
29
+
30
+ def get(self, uuid: str) -> World:
31
+ """Get a single world by UUID."""
32
+ return self._http.request("GET", f"worlds/{_q(uuid)}")
33
+
34
+ def save(self, uuid: str) -> None:
35
+ """Save a specific world to disk."""
36
+ return self._http.request("POST", f"worlds/{_q(uuid)}/save")
37
+
38
+ def download(self, uuid: str) -> Dict[str, str]:
39
+ """Get a download link/stream reference for a specific world."""
40
+ return self._http.request("GET", f"worlds/{_q(uuid)}/download")
41
+
42
+ def set_time(self, uuid: str, time: int) -> None:
43
+ """Set the in-game time for a world (0-24000)."""
44
+ return self._http.request("POST", f"worlds/{_q(uuid)}/time", body={"time": time}, form=True)
45
+
46
+ def set_weather(self, uuid: str, weather: Weather) -> None:
47
+ """Set the weather for a world.
48
+
49
+ Server-side (``WorldApi.setWorldWeather``) reads a single
50
+ ``weather`` enum string — "clear" | "rain" | "thunder" — not
51
+ separate storm/thundering booleans.
52
+ """
53
+ return self._http.request("POST", f"worlds/{_q(uuid)}/weather", body={"weather": weather}, form=True)
54
+
55
+ def get_entities(self, uuid: str) -> Dict[str, Any]:
56
+ """Get entity counts within a specific world."""
57
+ return self._http.request("GET", f"worlds/{_q(uuid)}/entities")
noxaeapi_sdk/py.typed ADDED
File without changes
noxaeapi_sdk/socket.py ADDED
@@ -0,0 +1,167 @@
1
+ """Realtime console/event socket.
2
+
3
+ Requires the optional ``websocket-client`` dependency:
4
+
5
+ pip install noxaeapi-sdk[ws]
6
+
7
+ The import is lazy — importing :mod:`noxaeapi_sdk` itself never requires
8
+ ``websocket-client`` to be installed; only calling
9
+ :meth:`~noxaeapi_sdk.client.NoxAeApiClient.connect` does.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import random
16
+ import threading
17
+ import time
18
+ import urllib.parse
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Callable, Dict, List, Optional, Set
21
+
22
+ Listener = Callable[[Any], None]
23
+
24
+ WS_EVENTS = ("open", "close", "error", "console", "event", "message")
25
+
26
+
27
+ def _to_ws_url(base_url: str, route: str, api_key: Optional[str] = None) -> str:
28
+ parsed = urllib.parse.urlsplit(f"{base_url.rstrip('/')}/v1/ws/{route.lstrip('/')}")
29
+ scheme = "wss" if parsed.scheme == "https" else "ws"
30
+ query = dict(urllib.parse.parse_qsl(parsed.query))
31
+ if api_key:
32
+ query["key"] = api_key
33
+ new_query = urllib.parse.urlencode(query)
34
+ return urllib.parse.urlunsplit((scheme, parsed.netloc, parsed.path, new_query, ""))
35
+
36
+
37
+ @dataclass
38
+ class NoxAeApiWsOptions:
39
+ """Options for :class:`NoxAeApiSocket`."""
40
+
41
+ base_url: str
42
+ api_key: Optional[str] = None
43
+ route: str = "events"
44
+ """Route suffix under the websocket base, e.g. "console" or "events"."""
45
+ auto_reconnect: bool = True
46
+ max_reconnect_delay: float = 30.0
47
+ """Max reconnect delay in seconds."""
48
+ extra: Dict[str, Any] = field(default_factory=dict)
49
+ """Extra kwargs forwarded to ``websocket.WebSocketApp``."""
50
+
51
+
52
+ class NoxAeApiSocket:
53
+ """Thin wrapper around the server's WebSocket endpoints (console tail and event broadcasts).
54
+
55
+ Runs the connection on a background thread and handles reconnection
56
+ with exponential backoff, so consumers can just attach listeners and
57
+ not think about the socket lifecycle.
58
+
59
+ Usage::
60
+
61
+ ws = client.connect(route="console")
62
+ ws.on("console", lambda line: print(line))
63
+ ws.on("close", lambda _: print("disconnected"))
64
+ """
65
+
66
+ def __init__(self, options: NoxAeApiWsOptions) -> None:
67
+ try:
68
+ import websocket # type: ignore[import-untyped]
69
+ except ImportError as exc: # pragma: no cover - exercised only when dep missing
70
+ raise ImportError(
71
+ "NoxAeApiSocket requires the optional 'websocket-client' package. "
72
+ "Install it with: pip install noxaeapi-sdk[ws]"
73
+ ) from exc
74
+
75
+ self._websocket_module = websocket
76
+ self._options = options
77
+ self._listeners: Dict[str, Set[Listener]] = {event: set() for event in WS_EVENTS}
78
+ self._reconnect_attempt = 0
79
+ self._closed_by_user = False
80
+ self._app: Optional[Any] = None
81
+ self._thread: Optional[threading.Thread] = None
82
+ self._open()
83
+
84
+ # -- connection lifecycle -------------------------------------------------
85
+
86
+ def _open(self) -> None:
87
+ url = _to_ws_url(self._options.base_url, self._options.route, self._options.api_key)
88
+
89
+ def on_open(_ws: Any) -> None:
90
+ self._reconnect_attempt = 0
91
+ self._emit("open", None)
92
+
93
+ def on_message(_ws: Any, message: Any) -> None:
94
+ text = message if isinstance(message, str) else message.decode("utf-8", errors="replace")
95
+ parsed = _safe_parse(text)
96
+ self._emit("message", parsed)
97
+ if isinstance(parsed, dict) and parsed.get("type") in ("console", "event"):
98
+ self._emit(parsed["type"], parsed)
99
+
100
+ def on_error(_ws: Any, error: Any) -> None:
101
+ self._emit("error", error)
102
+
103
+ def on_close(_ws: Any, _status_code: Any, _msg: Any) -> None:
104
+ self._emit("close", None)
105
+ if not self._closed_by_user and self._options.auto_reconnect:
106
+ self._schedule_reconnect()
107
+
108
+ self._app = self._websocket_module.WebSocketApp(
109
+ url,
110
+ on_open=on_open,
111
+ on_message=on_message,
112
+ on_error=on_error,
113
+ on_close=on_close,
114
+ **self._options.extra,
115
+ )
116
+ self._thread = threading.Thread(target=self._app.run_forever, daemon=True)
117
+ self._thread.start()
118
+
119
+ def _schedule_reconnect(self) -> None:
120
+ max_delay = self._options.max_reconnect_delay
121
+ delay = min(max_delay, 0.5 * (2 ** self._reconnect_attempt))
122
+ self._reconnect_attempt += 1
123
+
124
+ def _reconnect() -> None:
125
+ time.sleep(delay)
126
+ if not self._closed_by_user:
127
+ self._open()
128
+
129
+ threading.Thread(target=_reconnect, daemon=True).start()
130
+
131
+ # -- listeners --------------------------------------------------------
132
+
133
+ def on(self, event: str, listener: Listener) -> Callable[[], None]:
134
+ """Register a listener for ``event``. Returns an unsubscribe callable."""
135
+ if event not in self._listeners:
136
+ self._listeners[event] = set()
137
+ self._listeners[event].add(listener)
138
+ return lambda: self._listeners.get(event, set()).discard(listener)
139
+
140
+ def off(self, event: str, listener: Listener) -> None:
141
+ self._listeners.get(event, set()).discard(listener)
142
+
143
+ def _emit(self, event: str, payload: Any) -> None:
144
+ for listener in list(self._listeners.get(event, set())):
145
+ listener(payload)
146
+
147
+ # -- send/close ---------------------------------------------------------
148
+
149
+ def send(self, payload: Any) -> None:
150
+ """Send a raw payload over the socket, JSON-encoded if not already a string."""
151
+ if self._app is None or self._app.sock is None or not self._app.sock.connected:
152
+ raise RuntimeError("Cannot send: socket is not open")
153
+ data = payload if isinstance(payload, str) else json.dumps(payload)
154
+ self._app.send(data)
155
+
156
+ def close(self) -> None:
157
+ """Close the socket and stop reconnecting."""
158
+ self._closed_by_user = True
159
+ if self._app is not None:
160
+ self._app.close()
161
+
162
+
163
+ def _safe_parse(text: str) -> Any:
164
+ try:
165
+ return json.loads(text)
166
+ except (json.JSONDecodeError, TypeError):
167
+ return text
noxaeapi_sdk/types.py ADDED
@@ -0,0 +1,305 @@
1
+ """Response shape hints for the NoxAeApi REST API.
2
+
3
+ These are :class:`typing.TypedDict` definitions, not runtime-validated
4
+ models — every SDK method still just returns whatever JSON the server
5
+ sent, parsed into plain ``dict``/``list``/scalar values. The TypedDicts
6
+ exist purely so editors and type checkers (mypy/pyright) can give you
7
+ autocomplete and structural checks against the documented shapes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, List, Literal, Optional, Tuple, TypedDict, Union
13
+
14
+
15
+ class OnlinePlayer(TypedDict):
16
+ uuid: str
17
+ displayName: str
18
+ address: Optional[str]
19
+ port: Optional[int]
20
+ exhaustion: float
21
+ exp: float
22
+ expLevel: int
23
+ whitelisted: bool
24
+ banned: bool
25
+ op: bool
26
+ balance: Optional[float]
27
+ location: Optional[Tuple[float, float, float]]
28
+ dimension: Optional[str]
29
+ health: float
30
+ hunger: int
31
+ saturation: float
32
+ gamemode: str
33
+ lastPlayed: int
34
+ authenticated: Optional[bool]
35
+ registered: Optional[bool]
36
+
37
+
38
+ class OfflinePlayer(TypedDict):
39
+ uuid: str
40
+ displayName: str
41
+ whitelisted: bool
42
+ banned: bool
43
+ op: bool
44
+ balance: Optional[float]
45
+ lastPlayed: int
46
+
47
+
48
+ class ServerHealth(TypedDict):
49
+ cpus: int
50
+ uptime: int
51
+ totalMemory: int
52
+ maxMemory: int
53
+ freeMemory: int
54
+
55
+
56
+ class ServerBan(TypedDict):
57
+ target: str
58
+ source: Optional[str]
59
+ reason: Optional[str]
60
+ expiration: Optional[str]
61
+
62
+
63
+ class WhitelistEntry(TypedDict):
64
+ uuid: str
65
+ name: str
66
+
67
+
68
+ class ServerInfo(TypedDict):
69
+ name: str
70
+ motd: str
71
+ version: str
72
+ bukkitVersion: str
73
+ tps: str
74
+ health: ServerHealth
75
+ bannedIps: List[ServerBan]
76
+ bannedPlayers: List[ServerBan]
77
+ whitelistedPlayers: List[WhitelistEntry]
78
+ maxPlayers: int
79
+ onlinePlayers: int
80
+
81
+
82
+ class World(TypedDict):
83
+ name: str
84
+ uuid: str
85
+ time: int
86
+ storm: bool
87
+ thundering: bool
88
+ generateStructures: bool
89
+ allowAnimals: bool
90
+ allowMonsters: bool
91
+ difficulty: str
92
+ environment: str
93
+ seed: str
94
+
95
+
96
+ class Score(TypedDict):
97
+ entry: str
98
+ value: int
99
+
100
+
101
+ class Objective(TypedDict):
102
+ name: str
103
+ displayName: str
104
+ criterion: str
105
+ scores: List[Score]
106
+ displaySlot: Optional[str]
107
+
108
+
109
+ class Scoreboard(TypedDict):
110
+ objectives: List[str]
111
+ entries: List[str]
112
+
113
+
114
+ class InventoryItem(TypedDict):
115
+ id: str
116
+ count: int
117
+ slot: int
118
+
119
+
120
+ class Plugin(TypedDict):
121
+ name: str
122
+ enabled: bool
123
+ version: str
124
+ website: Optional[str]
125
+ authors: List[str]
126
+ depends: List[str]
127
+ softDepends: List[str]
128
+ apiVersion: Optional[str]
129
+ description: Optional[str]
130
+
131
+
132
+ class EconomyInfo(TypedDict, total=False):
133
+ available: bool
134
+
135
+
136
+ class PlayerBalance(TypedDict):
137
+ uuid: str
138
+ balance: float
139
+
140
+
141
+ class TopBalanceEntry(TypedDict):
142
+ uuid: str
143
+ balance: float
144
+
145
+
146
+ class GroupInfo(TypedDict):
147
+ name: str
148
+ permissions: List[str]
149
+
150
+
151
+ class PermissionNode(TypedDict):
152
+ permission: str
153
+ value: bool
154
+ expiry: int
155
+ server: Optional[str]
156
+ world: Optional[str]
157
+
158
+
159
+ class Advancement(TypedDict):
160
+ key: str
161
+ criteria: List[str]
162
+
163
+
164
+ class NoxAuthPlayerInfo(TypedDict):
165
+ uuid: str
166
+ name: str
167
+ registered: bool
168
+ authenticated: bool
169
+ lastIp: Optional[str]
170
+ lastLoginTime: Optional[int]
171
+ countryCode: Optional[str]
172
+ countryName: Optional[str]
173
+
174
+
175
+ class PasswordCheckResult(TypedDict):
176
+ name: str
177
+ valid: bool
178
+
179
+
180
+ class PlayerStats(TypedDict):
181
+ uuid: str
182
+ name: str
183
+ kills: int
184
+ deaths: int
185
+ playtime: int
186
+ blocksPlaced: int
187
+ blocksBroken: int
188
+
189
+
190
+ class SkillInfo(TypedDict):
191
+ uuid: str
192
+ skills: dict
193
+ """Skill name -> level."""
194
+ powerLevel: int
195
+
196
+
197
+ class CurrencyBalance(TypedDict):
198
+ uuid: str
199
+ name: Optional[str]
200
+ currency: str
201
+ balance: float
202
+
203
+
204
+ class CurrencyTopEntry(TypedDict):
205
+ uuid: str
206
+ name: str
207
+ balance: float
208
+
209
+
210
+ class LeaderboardSourceInfo(TypedDict):
211
+ id: str
212
+ displayName: str
213
+ available: bool
214
+ onlineOnly: bool
215
+ """True if this source can only rank currently-online players (e.g. mcMMO)."""
216
+
217
+
218
+ class LeaderboardEntry(TypedDict):
219
+ uuid: str
220
+ name: str
221
+ value: float
222
+
223
+
224
+ class NetworkServerStatus(TypedDict):
225
+ id: str
226
+ label: str
227
+ online: bool
228
+ server: Optional[ServerInfo]
229
+ players: List[OnlinePlayer]
230
+
231
+
232
+ class NetworkPlayersServerEntry(TypedDict):
233
+ id: str
234
+ label: str
235
+ online: bool
236
+ players: List[OnlinePlayer]
237
+
238
+
239
+ class NetworkPlayersResponse(TypedDict):
240
+ total: int
241
+ servers: List[NetworkPlayersServerEntry]
242
+
243
+
244
+ class NetworkFindPlayerResponse(TypedDict, total=False):
245
+ found: bool
246
+ server: str
247
+ player: OnlinePlayer
248
+
249
+
250
+ class NetworkHealthServerEntry(TypedDict, total=False):
251
+ id: str
252
+ label: str
253
+ online: bool
254
+ tps: Any
255
+ health: Any
256
+
257
+
258
+ class NetworkHealthResponse(TypedDict):
259
+ servers: List[NetworkHealthServerEntry]
260
+
261
+
262
+ NetworkBroadcastResponse = dict
263
+ """Per-server "success" | "error" result, keyed by network server ID."""
264
+
265
+
266
+ class NetworkHubNode(TypedDict):
267
+ id: str
268
+ label: str
269
+ online: bool
270
+ tps: str
271
+ onlinePlayers: int
272
+ maxPlayers: int
273
+ health: Any
274
+ """Opaque payload the backend reported in its last heartbeat."""
275
+ lastHeartbeatAt: int
276
+ """Unix epoch ms of the last heartbeat received, or 0 if never."""
277
+
278
+
279
+ class NetworkHubStatusResponse(TypedDict):
280
+ network: List[NetworkHubNode]
281
+
282
+
283
+ class NetworkHubPlayer(TypedDict, total=False):
284
+ uuid: str
285
+ name: str
286
+ server: str
287
+ """Backend server ID the player is currently connected to, if known."""
288
+
289
+
290
+ class NetworkHubPlayersResponse(TypedDict):
291
+ total: int
292
+ players: List[NetworkHubPlayer]
293
+
294
+
295
+ class NetworkHubFindPlayerResponse(TypedDict, total=False):
296
+ found: bool
297
+ player: NetworkHubPlayer
298
+
299
+
300
+ class NetworkHubBroadcastResponse(TypedDict):
301
+ delivered: int
302
+
303
+
304
+ Gamemode = Literal["survival", "creative", "adventure", "spectator"]
305
+ Weather = Literal["clear", "rain", "thunder"]
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.5
2
+ Name: noxaeapi-sdk
3
+ Version: 0.4.0
4
+ Summary: Typed Python SDK for the NoxAeApi REST + WebSocket API (Fabric, Bukkit/Spigot/Paper) and the NoxAeApi-Velocity network hub
5
+ Project-URL: Homepage, https://github.com/WumXPro/noxaeapi-sdk
6
+ Project-URL: Documentation, https://noxapi.noxlydev.xyz
7
+ Project-URL: Repository, https://github.com/WumXPro/noxaeapi-sdk
8
+ Project-URL: Issues, https://github.com/WumXPro/noxaeapi-sdk/issues
9
+ Author: WumX Labs
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: bukkit,fabric,minecraft,noxaeapi,noxlydev,paper,rest-client,sdk,spigot,velocity,wumx
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.8
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Games/Entertainment
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.8
27
+ Provides-Extra: dev
28
+ Requires-Dist: mypy>=1.0; extra == 'dev'
29
+ Requires-Dist: pytest>=7.0; extra == 'dev'
30
+ Provides-Extra: ws
31
+ Requires-Dist: websocket-client>=1.6; extra == 'ws'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # noxaeapi-sdk
35
+
36
+ Typed Python SDK for [NoxAeApi](https://github.com/WumXPro/noxaeapi-sdk), a REST + WebSocket API plugin/mod for Minecraft servers — ships for both Fabric and Bukkit/Spigot/PaperMC. The REST surface is identical across platforms, so this SDK works against either without any platform-specific configuration.
37
+
38
+ Zero required runtime dependencies — uses only the Python standard library (`urllib`) for HTTP. The realtime socket needs one optional extra.
39
+
40
+ 📖 **Docs:** [noxapi.noxlydev.xyz](https://noxapi.noxlydev.xyz)
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install noxaeapi-sdk
46
+
47
+ # with realtime console/event socket support:
48
+ pip install "noxaeapi-sdk[ws]"
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ ```python
54
+ from noxaeapi_sdk import NoxAeApiClient
55
+
56
+ client = NoxAeApiClient(base_url="http://localhost:8080", api_key="your-api-key")
57
+
58
+ players = client.players.list()
59
+ balance = client.economy.get_balance(players[0]["uuid"])
60
+ client.server.broadcast("Hello from the SDK!")
61
+
62
+ # Multi-currency (ExcellentEconomy), leaderboards, and network aggregator:
63
+ coins = client.economy.get_currency_balance("coins", players[0]["uuid"])
64
+ top = client.leaderboards.get_top("mcmmo-power", limit=10)
65
+ network = client.network.status_all()
66
+ ```
67
+
68
+ ### From environment variables
69
+
70
+ ```python
71
+ # Reads NOXAEAPI_BASE_URL and NOXAEAPI_KEY from os.environ.
72
+ # If you keep those in a .env file, load it yourself first (e.g. with
73
+ # python-dotenv) — the SDK never reads .env files or os.environ implicitly
74
+ # outside this method.
75
+ client = NoxAeApiClient.from_env()
76
+ ```
77
+
78
+ ### Realtime (console tail / events)
79
+
80
+ Requires the `ws` extra (`pip install "noxaeapi-sdk[ws]"`).
81
+
82
+ ```python
83
+ ws = client.connect(route="console")
84
+ ws.on("console", lambda line: print(line))
85
+ ws.on("close", lambda _: print("disconnected"))
86
+ ```
87
+
88
+ The socket runs on a background thread and auto-reconnects with exponential backoff on unexpected disconnects.
89
+
90
+ ## Error handling
91
+
92
+ All non-2xx responses raise a subclass of `NoxAeApiError`:
93
+
94
+ - `NoxAeApiUnauthorizedError` — 401, missing/invalid API key
95
+ - `NoxAeApiForbiddenError` — 403, key valid but not permitted for this endpoint
96
+ - `NoxAeApiNotFoundError` — 404
97
+ - `NoxAeApiRateLimitError` — 429 (SDK auto-retries these by default; raised only once retries are exhausted)
98
+ - `NoxAeApiServerError` — 5xx (also auto-retried by default)
99
+ - `NoxAeApiNetworkError` — request never completed (timeout, DNS, connection refused)
100
+
101
+ ```python
102
+ from noxaeapi_sdk import NoxAeApiForbiddenError
103
+
104
+ try:
105
+ client.server.restart()
106
+ except NoxAeApiForbiddenError:
107
+ print("This API key isn't allowed to restart the server.")
108
+ ```
109
+
110
+ ## Request encoding
111
+
112
+ The server is a Javalin app, and most endpoints read their body with `ctx.formParam(...)` — i.e. `application/x-www-form-urlencoded` — rather than JSON. The SDK follows the same split:
113
+
114
+ - **Form-urlencoded**: everything in `economy` (including the `economy.get_currency_balance`/`.pay_currency`/etc ExcellentEconomy methods), `players`, `server` (except `luckperms`/`noxauth`), `worlds`, `plugins`, `placeholders`, and `network.broadcast`.
115
+ - **JSON**: `client.luckperms.*` and `client.noxauth.check_password` only — these are read server-side with `ctx.bodyAsClass(...)`.
116
+ - **N/A (GET only)**: `client.leaderboards.*` and most of `client.network.*` are read-only.
117
+
118
+ If you're extending the SDK, check which encoding the corresponding Javalin handler uses before wiring up a new method, and pass `form=True` to `http.request(...)` if it's form-urlencoded (this is also the more common case). Getting this wrong won't raise a type error — the request just silently sends the wrong content type and the server won't see the field.
119
+
120
+ ## Optional modules
121
+
122
+ Some modules only work depending on the target server's setup:
123
+
124
+ - `client.luckperms.*` — requires the LuckPerms mod to be loaded on the server
125
+ - `client.noxauth.*` — requires `noxauth.enabled: true` in the server's `noxaeapi-config.yml`
126
+ - `client.economy.get_currency_balance()`/`.pay_currency()`/`.debit_currency()`/`.set_currency_balance()`/`.get_currency_top()`/`.list_currencies()` — requires ExcellentEconomy (raises a 424 error if it isn't installed)
127
+ - `client.network.*` — requires `network.enabled: true` with at least one backend server configured in the server's config
128
+
129
+ Calling these against a server without the corresponding feature enabled will fail (typically 404).
130
+
131
+ ## NoxAeApi-Velocity network hub
132
+
133
+ If your network runs the **NoxAeApi-Velocity** proxy plugin, use `NoxAeApiNetworkHubClient` instead of (or alongside) `NoxAeApiClient` — point it at the hub's own REST port, not a backend server's port. Backend servers push register/heartbeat updates to the hub over WebSocket, so hub calls answer from its in-memory registry rather than fanning out live requests the way `client.network.*` above does — and the response shapes differ accordingly (e.g. `players()` is one flat proxy-wide list, not a per-backend breakdown, and there's no hub equivalent of `network/health` — see each node's `health` field in `status_all()`/`status_by_id()` instead).
134
+
135
+ ```python
136
+ from noxaeapi_sdk import NoxAeApiNetworkHubClient
137
+
138
+ hub = NoxAeApiNetworkHubClient(base_url="http://localhost:9090", api_key="your-hub-key") # the hub's api-port, not a backend's port
139
+
140
+ status = hub.network.status_all()
141
+ players = hub.network.players()
142
+ found = hub.network.find_player(players["players"][0]["uuid"]) if players["players"] else None
143
+ hub.network.broadcast("Hello from the hub!")
144
+
145
+ # Reach a specific backend's own REST routes through the hub:
146
+ hub.network.forward("survival", "POST", "server/exec", body={"command": "say hi"}, form=True)
147
+ ```
148
+
149
+ Or from environment variables (`NOXAEAPI_HUB_BASE_URL` / `NOXAEAPI_HUB_KEY`, kept separate from `NoxAeApiClient.from_env()`'s `NOXAEAPI_*` vars so a process can hold both clients at once):
150
+
151
+ ```python
152
+ hub = NoxAeApiNetworkHubClient.from_env()
153
+ ```
154
+
155
+ ## Configuration
156
+
157
+ ```python
158
+ from noxaeapi_sdk import NoxAeApiClient, RetryOptions
159
+
160
+ client = NoxAeApiClient(
161
+ base_url="https://mc.example.com",
162
+ api_key="...",
163
+ timeout=10.0, # per-request timeout in seconds, default 10
164
+ retry=RetryOptions(
165
+ attempts=3, # total attempts including the first, default 3
166
+ base_delay_ms=300,
167
+ max_delay_ms=5000,
168
+ ),
169
+ # retry=False, # disable retries entirely
170
+ headers={"X-Extra": "..."},
171
+ )
172
+ ```
173
+
174
+ ## License
175
+
176
+ MIT