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,41 @@
1
+ """Typed Python SDK for the NoxAeApi REST + WebSocket API.
2
+
3
+ (Fabric, Bukkit/Spigot/Paper) and the NoxAeApi-Velocity network hub.
4
+
5
+ Example::
6
+
7
+ from noxaeapi_sdk import NoxAeApiClient
8
+
9
+ client = NoxAeApiClient(base_url="http://localhost:8080", api_key="your-api-key")
10
+ players = client.players.list()
11
+ balance = client.economy.get_balance(players[0]["uuid"])
12
+ client.server.broadcast("Hello from the SDK!")
13
+ """
14
+
15
+ from .client import NoxAeApiClient, NoxAeApiNetworkHubClient
16
+ from .errors import (
17
+ NoxAeApiError,
18
+ NoxAeApiForbiddenError,
19
+ NoxAeApiNetworkError,
20
+ NoxAeApiNotFoundError,
21
+ NoxAeApiRateLimitError,
22
+ NoxAeApiServerError,
23
+ NoxAeApiUnauthorizedError,
24
+ )
25
+ from .http_engine import NoxAeApiClientOptions, RetryOptions
26
+
27
+ __version__ = "0.4.0"
28
+
29
+ __all__ = [
30
+ "NoxAeApiClient",
31
+ "NoxAeApiNetworkHubClient",
32
+ "NoxAeApiClientOptions",
33
+ "RetryOptions",
34
+ "NoxAeApiError",
35
+ "NoxAeApiUnauthorizedError",
36
+ "NoxAeApiForbiddenError",
37
+ "NoxAeApiNotFoundError",
38
+ "NoxAeApiRateLimitError",
39
+ "NoxAeApiServerError",
40
+ "NoxAeApiNetworkError",
41
+ ]
noxaeapi_sdk/client.py ADDED
@@ -0,0 +1,170 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Any, Optional
5
+
6
+ from .http_engine import HttpEngine, NoxAeApiClientOptions
7
+ from .modules.economy import EconomyModule
8
+ from .modules.leaderboard import LeaderboardModule
9
+ from .modules.luckperms import LuckPermsModule
10
+ from .modules.misc import AdvancementsModule, PlaceholdersModule
11
+ from .modules.network import NetworkModule
12
+ from .modules.network_hub import NetworkHubModule
13
+ from .modules.noxauth import NoxAuthModule
14
+ from .modules.players import PlayersModule
15
+ from .modules.plugins import PluginsModule
16
+ from .modules.server import ServerModule
17
+ from .modules.skills import SkillsModule
18
+ from .modules.worlds import WorldsModule
19
+
20
+
21
+ class NoxAeApiClient:
22
+ """Client for a NoxAeApi server (Fabric mod or Bukkit/Spigot/Paper plugin).
23
+
24
+ Example::
25
+
26
+ client = NoxAeApiClient(base_url="http://localhost:8080", api_key="your-api-key")
27
+ players = client.players.list()
28
+ balance = client.economy.get_balance(players[0]["uuid"])
29
+ client.server.broadcast("Hello from the SDK!")
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ base_url: Optional[str] = None,
35
+ api_key: Optional[str] = None,
36
+ *,
37
+ options: Optional[NoxAeApiClientOptions] = None,
38
+ **kwargs: Any,
39
+ ) -> None:
40
+ """Create a client.
41
+
42
+ Either pass ``base_url`` (and optionally ``api_key``,
43
+ ``timeout``, ``retry``, ``headers``, ...) directly, or build a
44
+ :class:`~noxaeapi_sdk.http_engine.NoxAeApiClientOptions` yourself
45
+ and pass it as ``options=``.
46
+ """
47
+ if options is None:
48
+ if not base_url:
49
+ raise ValueError("NoxAeApiClient requires base_url (or options=NoxAeApiClientOptions(...))")
50
+ options = NoxAeApiClientOptions(base_url=base_url, api_key=api_key, **kwargs)
51
+
52
+ self._options = options
53
+ self._http = HttpEngine(options)
54
+
55
+ self.players = PlayersModule(self._http)
56
+ self.economy = EconomyModule(self._http)
57
+ self.server = ServerModule(self._http)
58
+ self.worlds = WorldsModule(self._http)
59
+ self.plugins = PluginsModule(self._http)
60
+ self.advancements = AdvancementsModule(self._http)
61
+ self.placeholders = PlaceholdersModule(self._http)
62
+ self.luckperms = LuckPermsModule(self._http)
63
+ """Only works if LuckPerms is loaded on the target server."""
64
+ self.noxauth = NoxAuthModule(self._http)
65
+ """Only works if ``noxauth.enabled: true`` is set in the server config."""
66
+ self.skills = SkillsModule(self._http)
67
+ """Requires mcMMO and/or AuraSkills to be loaded on the target server."""
68
+ self.leaderboards = LeaderboardModule(self._http)
69
+ """Generic ranked leaderboards (economy currencies, mcMMO, AuraSkills, ...)."""
70
+ self.network = NetworkModule(self._http)
71
+ """Only works if ``network.enabled: true`` is set in the server config.
72
+
73
+ This is NoxAeApi-main's built-in polling aggregator — it lives on
74
+ the *same* backend server you're already connected to and fans
75
+ requests out to the other backends listed in that server's own
76
+ config. If the network is running NoxAeApi-Velocity instead, use
77
+ :class:`NoxAeApiNetworkHubClient` (pointed at the proxy's hub
78
+ port) rather than this module — the hub replaces this aggregator
79
+ with a push model and its response shapes differ.
80
+ """
81
+
82
+ @classmethod
83
+ def from_env(cls, **overrides: Any) -> "NoxAeApiClient":
84
+ """Build a client from environment variables: ``NOXAEAPI_BASE_URL`` and ``NOXAEAPI_KEY``.
85
+
86
+ Convenience for scripts. The SDK never reads ``.env`` files or
87
+ ``os.environ`` implicitly outside of this method — use a library
88
+ like ``python-dotenv`` in your own app if you want that, then
89
+ call ``NoxAeApiClient.from_env()`` after it's loaded.
90
+ """
91
+ base_url = overrides.pop("base_url", None) or os.environ.get("NOXAEAPI_BASE_URL")
92
+ api_key = overrides.pop("api_key", None) or os.environ.get("NOXAEAPI_KEY")
93
+
94
+ if not base_url:
95
+ raise ValueError(
96
+ "NoxAeApiClient.from_env(): NOXAEAPI_BASE_URL is not set and no base_url override was given."
97
+ )
98
+
99
+ return cls(base_url=base_url, api_key=api_key, **overrides)
100
+
101
+ def connect(self, route: str = "events", **kwargs: Any) -> Any:
102
+ """Open a WebSocket connection to the server (console tail or event stream).
103
+
104
+ Requires the optional ``websocket-client`` package
105
+ (``pip install noxaeapi-sdk[ws]``).
106
+ """
107
+ from .socket import NoxAeApiSocket, NoxAeApiWsOptions
108
+
109
+ return NoxAeApiSocket(
110
+ NoxAeApiWsOptions(
111
+ base_url=self._options.base_url,
112
+ api_key=self._options.api_key,
113
+ route=route,
114
+ **kwargs,
115
+ )
116
+ )
117
+
118
+
119
+ class NoxAeApiNetworkHubClient:
120
+ """Client for the **NoxAeApi-Velocity** network hub.
121
+
122
+ This is a separate plugin that runs on the Velocity proxy, not on any
123
+ individual backend server. Point ``base_url`` at the hub's own REST
124
+ port (``NetworkHubConfig``'s ``api-port``), not a backend's port, and
125
+ use ``NOXAEAPI_HUB_*`` env vars (via :meth:`from_env`) if you keep
126
+ that separate from a regular backend's ``NOXAEAPI_*`` vars.
127
+
128
+ Only exposes ``.network`` — the hub doesn't run any of the other REST
129
+ modules (players, economy, worlds, ...) that a backend
130
+ :class:`NoxAeApiClient` does. To reach a specific backend's own
131
+ routes through the hub, use ``hub.network.forward(id, ...)``.
132
+ """
133
+
134
+ def __init__(
135
+ self,
136
+ base_url: Optional[str] = None,
137
+ api_key: Optional[str] = None,
138
+ *,
139
+ options: Optional[NoxAeApiClientOptions] = None,
140
+ **kwargs: Any,
141
+ ) -> None:
142
+ if options is None:
143
+ if not base_url:
144
+ raise ValueError(
145
+ "NoxAeApiNetworkHubClient requires base_url (or options=NoxAeApiClientOptions(...))"
146
+ )
147
+ options = NoxAeApiClientOptions(base_url=base_url, api_key=api_key, **kwargs)
148
+
149
+ http = HttpEngine(options)
150
+ self.network = NetworkHubModule(http)
151
+ """The network hub's aggregated view of every registered backend node."""
152
+
153
+ @classmethod
154
+ def from_env(cls, **overrides: Any) -> "NoxAeApiNetworkHubClient":
155
+ """Build a hub client from environment variables: ``NOXAEAPI_HUB_BASE_URL`` / ``NOXAEAPI_HUB_KEY``.
156
+
157
+ Same convenience as ``NoxAeApiClient.from_env()``, under separate
158
+ env var names so a process can hold both a backend client and a
159
+ hub client at once without the two colliding.
160
+ """
161
+ base_url = overrides.pop("base_url", None) or os.environ.get("NOXAEAPI_HUB_BASE_URL")
162
+ api_key = overrides.pop("api_key", None) or os.environ.get("NOXAEAPI_HUB_KEY")
163
+
164
+ if not base_url:
165
+ raise ValueError(
166
+ "NoxAeApiNetworkHubClient.from_env(): NOXAEAPI_HUB_BASE_URL is not set "
167
+ "and no base_url override was given."
168
+ )
169
+
170
+ return cls(base_url=base_url, api_key=api_key, **overrides)
noxaeapi_sdk/errors.py ADDED
@@ -0,0 +1,132 @@
1
+ """Exception hierarchy for the NoxAeApi SDK.
2
+
3
+ Every non-2xx HTTP response from a NoxAeApi server raises a subclass of
4
+ :class:`NoxAeApiError`. Network-level failures (DNS, connection refused,
5
+ timeouts) raise :class:`NoxAeApiNetworkError` instead, which is *not* a
6
+ subclass of :class:`NoxAeApiError` since no response was ever received.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Optional
12
+
13
+
14
+ class NoxAeApiError(Exception):
15
+ """Base error for any non-2xx response from a NoxAeApi server.
16
+
17
+ Prefer catching one of the more specific subclasses below when you
18
+ need to branch on the failure reason.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ message: str,
24
+ *,
25
+ status: int,
26
+ method: str,
27
+ path: str,
28
+ body: Any = None,
29
+ ) -> None:
30
+ super().__init__(message)
31
+ self.status = status
32
+ self.method = method
33
+ self.path = path
34
+ self.body = body
35
+
36
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
37
+ return f"{type(self).__name__}(status={self.status}, method={self.method!r}, path={self.path!r})"
38
+
39
+
40
+ class NoxAeApiUnauthorizedError(NoxAeApiError):
41
+ """401 — the API key is missing or not recognized by the server."""
42
+
43
+ def __init__(self, *, status: int, method: str, path: str, body: Any = None) -> None:
44
+ super().__init__(
45
+ f"Unauthorized: the API key was missing or invalid for {method} {path}",
46
+ status=status,
47
+ method=method,
48
+ path=path,
49
+ body=body,
50
+ )
51
+
52
+
53
+ class NoxAeApiForbiddenError(NoxAeApiError):
54
+ """403 — the key is valid but isn't allowed to call this endpoint.
55
+
56
+ This is a server-side permission decision (e.g. a read-only key
57
+ hitting a write route); the SDK does not try to predict or enforce
58
+ it client-side.
59
+ """
60
+
61
+ def __init__(self, *, status: int, method: str, path: str, body: Any = None) -> None:
62
+ super().__init__(
63
+ f"Forbidden: this API key does not have permission to call {method} {path}",
64
+ status=status,
65
+ method=method,
66
+ path=path,
67
+ body=body,
68
+ )
69
+
70
+
71
+ class NoxAeApiNotFoundError(NoxAeApiError):
72
+ """404 — the target resource (player, world, plugin, etc.) wasn't found."""
73
+
74
+ def __init__(self, *, status: int, method: str, path: str, body: Any = None) -> None:
75
+ super().__init__(
76
+ f"Not found: {method} {path}",
77
+ status=status,
78
+ method=method,
79
+ path=path,
80
+ body=body,
81
+ )
82
+
83
+
84
+ class NoxAeApiRateLimitError(NoxAeApiError):
85
+ """429 — rate limited.
86
+
87
+ ``retry_after_ms`` is populated when the server sends a
88
+ ``Retry-After`` header. The SDK auto-retries these by default; this
89
+ is only raised once retries are exhausted (or retries are disabled).
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ *,
95
+ status: int,
96
+ method: str,
97
+ path: str,
98
+ body: Any = None,
99
+ retry_after_ms: Optional[int] = None,
100
+ ) -> None:
101
+ suffix = f" — retry after {retry_after_ms}ms" if retry_after_ms else ""
102
+ super().__init__(
103
+ f"Rate limited on {method} {path}{suffix}",
104
+ status=status,
105
+ method=method,
106
+ path=path,
107
+ body=body,
108
+ )
109
+ self.retry_after_ms = retry_after_ms
110
+
111
+
112
+ class NoxAeApiServerError(NoxAeApiError):
113
+ """5xx — the server errored out. Usually safe to retry, and auto-retried by default."""
114
+
115
+ def __init__(self, *, status: int, method: str, path: str, body: Any = None) -> None:
116
+ super().__init__(
117
+ f"Server error ({status}) on {method} {path}",
118
+ status=status,
119
+ method=method,
120
+ path=path,
121
+ body=body,
122
+ )
123
+
124
+
125
+ class NoxAeApiNetworkError(Exception):
126
+ """The request could not complete at all (DNS, connection refused, timeout)."""
127
+
128
+ def __init__(self, message: str, *, method: str, path: str, cause: Optional[BaseException] = None) -> None:
129
+ super().__init__(message)
130
+ self.method = method
131
+ self.path = path
132
+ self.__cause__ = cause
@@ -0,0 +1,259 @@
1
+ """Low-level HTTP engine shared by every module.
2
+
3
+ Zero third-party runtime dependencies — built on ``urllib`` from the
4
+ standard library, mirroring the zero-dependency ethos of the original
5
+ JS/TS SDK (which uses native ``fetch``).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import random
12
+ import time
13
+ import urllib.error
14
+ import urllib.parse
15
+ import urllib.request
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Callable, Dict, Optional, Union
18
+
19
+ from .errors import (
20
+ NoxAeApiError,
21
+ NoxAeApiForbiddenError,
22
+ NoxAeApiNetworkError,
23
+ NoxAeApiNotFoundError,
24
+ NoxAeApiRateLimitError,
25
+ NoxAeApiServerError,
26
+ NoxAeApiUnauthorizedError,
27
+ )
28
+
29
+ Method = str # "GET" | "POST" | "PUT" | "DELETE" | "PATCH"
30
+ QueryValue = Union[str, int, float, bool, None]
31
+
32
+
33
+ @dataclass
34
+ class RetryOptions:
35
+ """Retry behavior for network errors, 429s, and 5xx responses."""
36
+
37
+ attempts: int = 3
38
+ """Max number of attempts including the first one. Default 3."""
39
+
40
+ base_delay_ms: int = 300
41
+ """Base delay in ms used for exponential backoff."""
42
+
43
+ max_delay_ms: int = 5000
44
+ """Upper bound for any single backoff delay."""
45
+
46
+
47
+ @dataclass
48
+ class NoxAeApiClientOptions:
49
+ """Options accepted by :class:`NoxAeApiClient` / :class:`NoxAeApiNetworkHubClient`."""
50
+
51
+ base_url: str
52
+ """Base URL of the server, e.g. ``"http://localhost:8080"``."""
53
+
54
+ api_key: Optional[str] = None
55
+ """The API key configured on the server (sent as the ``key`` header)."""
56
+
57
+ timeout: float = 10.0
58
+ """Request timeout in seconds. Default 10."""
59
+
60
+ retry: Union[RetryOptions, bool, None] = True
61
+ """Retry options, ``True`` for defaults, or ``False`` to disable retries."""
62
+
63
+ headers: Dict[str, str] = field(default_factory=dict)
64
+ """Extra headers sent on every request."""
65
+
66
+ opener: Optional[Callable[[urllib.request.Request, float], Any]] = None
67
+ """Override the low-level opener, mainly for testing.
68
+
69
+ Must be a callable ``(request, timeout) -> http.client.HTTPResponse``
70
+ (or a context-manager-compatible object with the same interface as
71
+ what ``urllib.request.urlopen`` returns). Defaults to
72
+ ``urllib.request.urlopen``.
73
+ """
74
+
75
+
76
+ def _resolve_retry(retry: Union[RetryOptions, bool, None]) -> Union[RetryOptions, None]:
77
+ if retry is False or retry is None:
78
+ return None
79
+ if retry is True:
80
+ return RetryOptions()
81
+ return retry
82
+
83
+
84
+ def _sleep(seconds: float) -> None:
85
+ if seconds > 0:
86
+ time.sleep(seconds)
87
+
88
+
89
+ def _backoff_delay_seconds(attempt: int, opts: RetryOptions) -> float:
90
+ exp = min(opts.max_delay_ms, opts.base_delay_ms * (2 ** attempt))
91
+ # full jitter
92
+ return (random.random() * exp) / 1000.0
93
+
94
+
95
+ def _encode_form_body(body: Any) -> bytes:
96
+ """Encode a plain dict as ``application/x-www-form-urlencoded``.
97
+
98
+ Matches what Javalin's ``ctx.formParam(name)`` reads server-side.
99
+ ``None`` values are omitted so optional fields can be left out
100
+ entirely rather than sent as the literal string "None".
101
+ """
102
+ params: list[tuple[str, str]] = []
103
+ if isinstance(body, dict):
104
+ for key, value in body.items():
105
+ if value is None:
106
+ continue
107
+ if isinstance(value, bool):
108
+ params.append((key, "true" if value else "false"))
109
+ elif isinstance(value, (str, int, float)):
110
+ params.append((key, str(value)))
111
+ else:
112
+ params.append((key, json.dumps(value)))
113
+ return urllib.parse.urlencode(params).encode("utf-8")
114
+
115
+
116
+ def _safe_json_parse(text: Optional[str]) -> Any:
117
+ if not text:
118
+ return None
119
+ try:
120
+ return json.loads(text)
121
+ except (json.JSONDecodeError, TypeError):
122
+ return text
123
+
124
+
125
+ def _parse_retry_after(header_value: str) -> Optional[int]:
126
+ try:
127
+ return int(float(header_value) * 1000)
128
+ except ValueError:
129
+ pass
130
+ try:
131
+ from email.utils import parsedate_to_datetime
132
+
133
+ dt = parsedate_to_datetime(header_value)
134
+ if dt is None:
135
+ return None
136
+ delta_ms = (dt.timestamp() - time.time()) * 1000
137
+ return max(0, int(delta_ms))
138
+ except (TypeError, ValueError):
139
+ return None
140
+
141
+
142
+ class HttpEngine:
143
+ """Handles request building, encoding, retries, and error mapping."""
144
+
145
+ def __init__(self, options: NoxAeApiClientOptions) -> None:
146
+ if not options.base_url:
147
+ raise ValueError("NoxAeApiClient requires a non-empty base_url")
148
+ self._base_url = options.base_url.rstrip("/")
149
+ self._api_key = options.api_key
150
+ self._timeout = options.timeout
151
+ self._retry = _resolve_retry(options.retry)
152
+ self._extra_headers = dict(options.headers or {})
153
+ self._opener = options.opener or (
154
+ lambda req, timeout: urllib.request.urlopen(req, timeout=timeout)
155
+ )
156
+
157
+ def _build_url(self, path: str, query: Optional[Dict[str, QueryValue]] = None) -> str:
158
+ url = f"{self._base_url}/v1/{path.lstrip('/')}"
159
+ if query:
160
+ filtered = {k: v for k, v in query.items() if v is not None}
161
+ if filtered:
162
+ url += "?" + urllib.parse.urlencode(filtered)
163
+ return url
164
+
165
+ def request(
166
+ self,
167
+ method: Method,
168
+ path: str,
169
+ *,
170
+ body: Any = None,
171
+ query: Optional[Dict[str, QueryValue]] = None,
172
+ form: bool = False,
173
+ ) -> Any:
174
+ """Perform a request and return the parsed JSON body (or ``None`` for empty/204 responses)."""
175
+ url = self._build_url(path, query)
176
+ max_attempts = self._retry.attempts if self._retry else 1
177
+
178
+ last_error: Optional[BaseException] = None
179
+
180
+ for attempt in range(max_attempts):
181
+ headers: Dict[str, str] = {"Accept": "application/json", **self._extra_headers}
182
+ if self._api_key:
183
+ headers["key"] = self._api_key
184
+
185
+ data: Optional[bytes] = None
186
+ if body is not None:
187
+ if form:
188
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
189
+ data = _encode_form_body(body)
190
+ else:
191
+ headers["Content-Type"] = "application/json"
192
+ data = json.dumps(body).encode("utf-8")
193
+
194
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
195
+
196
+ try:
197
+ with self._opener(req, self._timeout) as response:
198
+ status = response.status
199
+ raw = response.read()
200
+ text = raw.decode("utf-8") if raw else ""
201
+ if status == 204 or not text:
202
+ return None
203
+ return json.loads(text)
204
+
205
+ except urllib.error.HTTPError as http_err:
206
+ error_text = http_err.read().decode("utf-8", errors="replace") if http_err.fp else None
207
+ parsed_body = _safe_json_parse(error_text)
208
+ status = http_err.code
209
+ info = dict(status=status, method=method, path=path, body=parsed_body)
210
+
211
+ if status == 401:
212
+ raise NoxAeApiUnauthorizedError(**info) from None
213
+ if status == 403:
214
+ raise NoxAeApiForbiddenError(**info) from None
215
+ if status == 404:
216
+ raise NoxAeApiNotFoundError(**info) from None
217
+
218
+ if status == 429:
219
+ retry_after_header = http_err.headers.get("Retry-After") if http_err.headers else None
220
+ retry_after_ms = _parse_retry_after(retry_after_header) if retry_after_header else None
221
+ err = NoxAeApiRateLimitError(retry_after_ms=retry_after_ms, **info)
222
+ if self._retry and attempt < max_attempts - 1:
223
+ last_error = err
224
+ _sleep((retry_after_ms / 1000.0) if retry_after_ms else _backoff_delay_seconds(attempt, self._retry))
225
+ continue
226
+ raise err from None
227
+
228
+ if status >= 500:
229
+ err = NoxAeApiServerError(**info)
230
+ if self._retry and attempt < max_attempts - 1:
231
+ last_error = err
232
+ _sleep(_backoff_delay_seconds(attempt, self._retry))
233
+ continue
234
+ raise err from None
235
+
236
+ raise NoxAeApiError(f"Unexpected status {status} on {method} {path}", **info) from None
237
+
238
+ except NoxAeApiError:
239
+ raise
240
+
241
+ except Exception as err: # noqa: BLE001 - network/timeout/DNS errors of many types
242
+ is_timeout = isinstance(err, TimeoutError) or "timed out" in str(err).lower()
243
+ message = (
244
+ f"Request timed out after {self._timeout}s: {method} {path}"
245
+ if is_timeout
246
+ else f"Network error on {method} {path}: {err}"
247
+ )
248
+ network_err = NoxAeApiNetworkError(message, method=method, path=path, cause=err)
249
+
250
+ if self._retry and attempt < max_attempts - 1:
251
+ last_error = network_err
252
+ _sleep(_backoff_delay_seconds(attempt, self._retry))
253
+ continue
254
+ raise network_err from err
255
+
256
+ # Unreachable in practice.
257
+ if isinstance(last_error, BaseException):
258
+ raise last_error
259
+ raise RuntimeError("Request failed after retries")
File without changes
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import urllib.parse
4
+ from typing import List, Literal, Optional
5
+
6
+ from ..http_engine import HttpEngine
7
+ from ..types import CurrencyBalance, CurrencyTopEntry, EconomyInfo, PlayerBalance, TopBalanceEntry
8
+
9
+
10
+ def _q(value: str) -> str:
11
+ return urllib.parse.quote(value, safe="")
12
+
13
+
14
+ class EconomyModule:
15
+ def __init__(self, http: HttpEngine) -> None:
16
+ self._http = http
17
+
18
+ def info(self) -> EconomyInfo:
19
+ """Get info about the connected economy provider (Impactor on Fabric, Vault on Bukkit/Spigot/Paper)."""
20
+ return self._http.request("GET", "economy")
21
+
22
+ def get_balance(self, uuid: str) -> PlayerBalance:
23
+ """Get a player's balance."""
24
+ return self._http.request("GET", f"economy/balance/{_q(uuid)}")
25
+
26
+ def get_top_balance(self, limit: Optional[int] = None) -> List[TopBalanceEntry]:
27
+ """Get the top balances leaderboard."""
28
+ return self._http.request("GET", "economy/top", query={"limit": limit})
29
+
30
+ def pay(self, uuid: str, amount: float) -> None:
31
+ """Pay an amount to a player (adds to their balance)."""
32
+ return self._http.request("POST", "economy/pay", body={"uuid": uuid, "amount": amount}, form=True)
33
+
34
+ def debit(self, uuid: str, amount: float) -> None:
35
+ """Debit an amount from a player (subtracts from their balance)."""
36
+ return self._http.request("POST", "economy/debit", body={"uuid": uuid, "amount": amount}, form=True)
37
+
38
+ # --- ExcellentEconomy multi-currency (native API, not Vault) ----------
39
+ #
40
+ # These endpoints talk directly to ExcellentEconomy's Developer API
41
+ # rather than Vault, so they work with any currency configured on the
42
+ # server (coins, gems, tokens, ...) instead of only the single
43
+ # Vault-linked "primary" currency exposed above. They raise
44
+ # `NoxAeApiError` with a 424 status if ExcellentEconomy isn't installed
45
+ # on the target server, and a 404 if the given currency ID doesn't exist.
46
+
47
+ def list_currencies(self) -> List[str]:
48
+ """List all currency IDs configured on ExcellentEconomy."""
49
+ return self._http.request("GET", "economy/currencies")
50
+
51
+ def get_currency_balance(self, currency: str, uuid: str) -> CurrencyBalance:
52
+ """Get a player's balance for a specific ExcellentEconomy currency."""
53
+ return self._http.request("GET", f"economy/currency/{_q(currency)}/balance/{_q(uuid)}")
54
+
55
+ def pay_currency(self, currency: str, uuid: str, amount: float) -> Literal["success", "failure"]:
56
+ """Pay a player in a specific currency (adds ``amount``). ``amount`` must be > 0."""
57
+ return self._http.request(
58
+ "POST", f"economy/currency/{_q(currency)}/pay", body={"uuid": uuid, "amount": amount}, form=True
59
+ )
60
+
61
+ def debit_currency(self, currency: str, uuid: str, amount: float) -> Literal["success", "failure"]:
62
+ """Debit a player in a specific currency (subtracts ``amount``). ``amount`` must be > 0."""
63
+ return self._http.request(
64
+ "POST", f"economy/currency/{_q(currency)}/debit", body={"uuid": uuid, "amount": amount}, form=True
65
+ )
66
+
67
+ def set_currency_balance(self, currency: str, uuid: str, amount: float) -> Literal["success", "failure"]:
68
+ """Set a player's balance for a specific currency to an exact amount (``amount`` must be >= 0)."""
69
+ return self._http.request(
70
+ "POST", f"economy/currency/{_q(currency)}/set", body={"uuid": uuid, "amount": amount}, form=True
71
+ )
72
+
73
+ def get_currency_top(self, currency: str, limit: Optional[int] = None) -> List[CurrencyTopEntry]:
74
+ """Get the top balances leaderboard for a specific currency."""
75
+ return self._http.request("GET", f"economy/currency/{_q(currency)}/top", query={"limit": limit})