dominusnode 1.0.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.
- dominusnode/__init__.py +151 -0
- dominusnode/admin.py +252 -0
- dominusnode/agent_wallet.py +240 -0
- dominusnode/auth.py +271 -0
- dominusnode/client.py +457 -0
- dominusnode/constants.py +18 -0
- dominusnode/errors.py +91 -0
- dominusnode/http_client.py +435 -0
- dominusnode/keys.py +88 -0
- dominusnode/plans.py +93 -0
- dominusnode/proxy.py +248 -0
- dominusnode/py.typed +0 -0
- dominusnode/sessions.py +55 -0
- dominusnode/slots.py +60 -0
- dominusnode/teams.py +339 -0
- dominusnode/token_manager.py +235 -0
- dominusnode/types.py +521 -0
- dominusnode/usage.py +230 -0
- dominusnode/wallet.py +189 -0
- dominusnode/wallet_auth.py +241 -0
- dominusnode/x402.py +88 -0
- dominusnode-1.0.0.dist-info/LICENSE +21 -0
- dominusnode-1.0.0.dist-info/METADATA +13 -0
- dominusnode-1.0.0.dist-info/RECORD +26 -0
- dominusnode-1.0.0.dist-info/WHEEL +5 -0
- dominusnode-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""HTTP client wrappers for sync and async usage.
|
|
2
|
+
|
|
3
|
+
Handles:
|
|
4
|
+
- User-Agent injection
|
|
5
|
+
- Authorization header from token manager
|
|
6
|
+
- Automatic retry on 429 (once)
|
|
7
|
+
- Status-code-to-exception mapping
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import random
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any, Dict, Optional
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from .constants import USER_AGENT
|
|
20
|
+
from .errors import (
|
|
21
|
+
AuthenticationError,
|
|
22
|
+
AuthorizationError,
|
|
23
|
+
ConflictError,
|
|
24
|
+
DominusNodeError,
|
|
25
|
+
InsufficientBalanceError,
|
|
26
|
+
NetworkError,
|
|
27
|
+
NotFoundError,
|
|
28
|
+
RateLimitError,
|
|
29
|
+
ServerError,
|
|
30
|
+
ValidationError,
|
|
31
|
+
)
|
|
32
|
+
from .token_manager import AsyncTokenManager, TokenManager
|
|
33
|
+
|
|
34
|
+
# Maximum response body size to prevent OOM from oversized API response
|
|
35
|
+
MAX_RESPONSE_BYTES = 10 * 1024 * 1024 # 10MB
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _extract_error_message(response: httpx.Response) -> str:
|
|
39
|
+
"""Try to extract an error message from the JSON body.
|
|
40
|
+
|
|
41
|
+
All extracted messages are truncated to 500 chars to prevent
|
|
42
|
+
unbounded error body content from propagating into exception strings.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
body = response.json()
|
|
46
|
+
if isinstance(body, dict):
|
|
47
|
+
msg = body.get("error", response.reason_phrase or "Unknown error")
|
|
48
|
+
return str(msg)[:500]
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
fallback = response.reason_phrase or f"HTTP {response.status_code}"
|
|
52
|
+
return str(fallback)[:500]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
56
|
+
"""Map HTTP status codes to SDK exceptions."""
|
|
57
|
+
status = response.status_code
|
|
58
|
+
if 200 <= status < 300:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
msg = _extract_error_message(response)
|
|
62
|
+
|
|
63
|
+
if status == 400:
|
|
64
|
+
raise ValidationError(msg)
|
|
65
|
+
if status == 401:
|
|
66
|
+
raise AuthenticationError(msg)
|
|
67
|
+
if status == 402:
|
|
68
|
+
raise InsufficientBalanceError(msg)
|
|
69
|
+
if status == 403:
|
|
70
|
+
raise AuthorizationError(msg)
|
|
71
|
+
if status == 404:
|
|
72
|
+
raise NotFoundError(msg)
|
|
73
|
+
if status == 409:
|
|
74
|
+
raise ConflictError(msg)
|
|
75
|
+
if status == 429:
|
|
76
|
+
retry_after = 60
|
|
77
|
+
ra_header = response.headers.get("retry-after")
|
|
78
|
+
if ra_header:
|
|
79
|
+
try:
|
|
80
|
+
retry_after = int(ra_header)
|
|
81
|
+
except ValueError:
|
|
82
|
+
pass
|
|
83
|
+
raise RateLimitError(msg, retry_after_seconds=retry_after)
|
|
84
|
+
if status >= 500:
|
|
85
|
+
raise ServerError(msg, status_code=status)
|
|
86
|
+
|
|
87
|
+
raise DominusNodeError(msg, status_code=status)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _parse_retry_after(response: httpx.Response) -> float:
|
|
91
|
+
"""Parse Retry-After header, defaulting to 1 second."""
|
|
92
|
+
ra = response.headers.get("retry-after")
|
|
93
|
+
if ra:
|
|
94
|
+
try:
|
|
95
|
+
val = float(ra)
|
|
96
|
+
# Guard against NaN/Infinity from malformed headers
|
|
97
|
+
if not (val == val) or val == float("inf"): # NaN check + infinity check
|
|
98
|
+
return 1.0
|
|
99
|
+
return max(val, 0.1)
|
|
100
|
+
except ValueError:
|
|
101
|
+
pass
|
|
102
|
+
return 1.0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _add_jitter(seconds: float) -> float:
|
|
106
|
+
"""Add ±10% jitter to prevent thundering-herd on rate-limited endpoints."""
|
|
107
|
+
jitter = seconds * 0.2 * (random.random() - 0.5)
|
|
108
|
+
return max(0.1, seconds + jitter)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
112
|
+
# Sync HTTP Client
|
|
113
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class SyncHttpClient:
|
|
117
|
+
"""Synchronous HTTP client wrapping httpx.Client."""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
base_url: str,
|
|
122
|
+
token_manager: TokenManager,
|
|
123
|
+
timeout: float = 30.0,
|
|
124
|
+
) -> None:
|
|
125
|
+
self._base_url = base_url.rstrip("/")
|
|
126
|
+
self._token_manager = token_manager
|
|
127
|
+
self._client = httpx.Client(
|
|
128
|
+
base_url=self._base_url,
|
|
129
|
+
timeout=timeout,
|
|
130
|
+
headers={"User-Agent": USER_AGENT},
|
|
131
|
+
follow_redirects=False, # Prevent HTTPS→HTTP credential leakage
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def close(self) -> None:
|
|
135
|
+
self._client.close()
|
|
136
|
+
|
|
137
|
+
def _build_headers(
|
|
138
|
+
self, extra: Optional[Dict[str, str]] = None, *, requires_auth: bool = True,
|
|
139
|
+
) -> Dict[str, str]:
|
|
140
|
+
headers: Dict[str, str] = {}
|
|
141
|
+
# Only attach Authorization header when auth is required.
|
|
142
|
+
# Public endpoints (slots, waitlist) must not leak credentials.
|
|
143
|
+
if requires_auth:
|
|
144
|
+
token = self._token_manager.get_valid_token()
|
|
145
|
+
if token:
|
|
146
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
147
|
+
if extra:
|
|
148
|
+
headers.update(extra)
|
|
149
|
+
return headers
|
|
150
|
+
|
|
151
|
+
def request(
|
|
152
|
+
self,
|
|
153
|
+
method: str,
|
|
154
|
+
path: str,
|
|
155
|
+
*,
|
|
156
|
+
json: Any = None,
|
|
157
|
+
params: Optional[Dict[str, Any]] = None,
|
|
158
|
+
headers: Optional[Dict[str, str]] = None,
|
|
159
|
+
requires_auth: bool = True,
|
|
160
|
+
) -> httpx.Response:
|
|
161
|
+
"""Execute an HTTP request with auto-retry on 401 (token refresh) and 429."""
|
|
162
|
+
merged_headers = self._build_headers(headers, requires_auth=requires_auth)
|
|
163
|
+
try:
|
|
164
|
+
response = self._client.request(
|
|
165
|
+
method,
|
|
166
|
+
path,
|
|
167
|
+
json=json,
|
|
168
|
+
params=params,
|
|
169
|
+
headers=merged_headers,
|
|
170
|
+
)
|
|
171
|
+
except httpx.HTTPError as exc:
|
|
172
|
+
raise NetworkError(str(exc)) from exc
|
|
173
|
+
|
|
174
|
+
# Response size limit — check Content-Length header if available
|
|
175
|
+
content_length = response.headers.get("content-length")
|
|
176
|
+
if content_length:
|
|
177
|
+
try:
|
|
178
|
+
if int(content_length) > MAX_RESPONSE_BYTES:
|
|
179
|
+
raise NetworkError(f"Response too large: {content_length} bytes")
|
|
180
|
+
except ValueError:
|
|
181
|
+
pass # Malformed Content-Length header — skip size check
|
|
182
|
+
|
|
183
|
+
# Also check actual body size (chunked transfer encoding may omit Content-Length)
|
|
184
|
+
if len(response.content) > MAX_RESPONSE_BYTES:
|
|
185
|
+
raise NetworkError(f"Response body too large: {len(response.content)} bytes")
|
|
186
|
+
|
|
187
|
+
# Auto-retry once on 401 after force-refreshing the token
|
|
188
|
+
if response.status_code == 401 and requires_auth and self._token_manager.has_refresh_token:
|
|
189
|
+
self._token_manager.force_refresh()
|
|
190
|
+
merged_headers = self._build_headers(headers, requires_auth=requires_auth)
|
|
191
|
+
try:
|
|
192
|
+
response = self._client.request(
|
|
193
|
+
method,
|
|
194
|
+
path,
|
|
195
|
+
json=json,
|
|
196
|
+
params=params,
|
|
197
|
+
headers=merged_headers,
|
|
198
|
+
)
|
|
199
|
+
except httpx.HTTPError as exc:
|
|
200
|
+
raise NetworkError(str(exc)) from exc
|
|
201
|
+
# Size check on 401-retry response
|
|
202
|
+
if len(response.content) > MAX_RESPONSE_BYTES:
|
|
203
|
+
raise NetworkError(f"Response body too large: {len(response.content)} bytes")
|
|
204
|
+
|
|
205
|
+
# Auto-retry once on 429
|
|
206
|
+
if response.status_code == 429:
|
|
207
|
+
wait = _parse_retry_after(response)
|
|
208
|
+
time.sleep(_add_jitter(min(wait, 10.0))) # Cap at 10s + jitter
|
|
209
|
+
# Rebuild headers to get fresh token in case it expired during wait
|
|
210
|
+
merged_headers = self._build_headers(headers, requires_auth=requires_auth)
|
|
211
|
+
try:
|
|
212
|
+
response = self._client.request(
|
|
213
|
+
method,
|
|
214
|
+
path,
|
|
215
|
+
json=json,
|
|
216
|
+
params=params,
|
|
217
|
+
headers=merged_headers,
|
|
218
|
+
)
|
|
219
|
+
except httpx.HTTPError as exc:
|
|
220
|
+
raise NetworkError(str(exc)) from exc
|
|
221
|
+
# Size check on 429-retry response
|
|
222
|
+
if len(response.content) > MAX_RESPONSE_BYTES:
|
|
223
|
+
raise NetworkError(f"Response body too large: {len(response.content)} bytes")
|
|
224
|
+
|
|
225
|
+
_raise_for_status(response)
|
|
226
|
+
return response
|
|
227
|
+
|
|
228
|
+
def get(
|
|
229
|
+
self, path: str, *, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
|
|
230
|
+
) -> Any:
|
|
231
|
+
resp = self.request("GET", path, params=params, requires_auth=requires_auth)
|
|
232
|
+
if resp.status_code == 204:
|
|
233
|
+
return None
|
|
234
|
+
return resp.json()
|
|
235
|
+
|
|
236
|
+
def post(
|
|
237
|
+
self, path: str, *, json: Any = None, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
|
|
238
|
+
) -> Any:
|
|
239
|
+
resp = self.request("POST", path, json=json, params=params, requires_auth=requires_auth)
|
|
240
|
+
if resp.status_code == 204:
|
|
241
|
+
return None
|
|
242
|
+
return resp.json()
|
|
243
|
+
|
|
244
|
+
def put(
|
|
245
|
+
self, path: str, *, json: Any = None, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
|
|
246
|
+
) -> Any:
|
|
247
|
+
resp = self.request("PUT", path, json=json, params=params, requires_auth=requires_auth)
|
|
248
|
+
if resp.status_code == 204:
|
|
249
|
+
return None
|
|
250
|
+
return resp.json()
|
|
251
|
+
|
|
252
|
+
def patch(
|
|
253
|
+
self, path: str, *, json: Any = None, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
|
|
254
|
+
) -> Any:
|
|
255
|
+
resp = self.request("PATCH", path, json=json, params=params, requires_auth=requires_auth)
|
|
256
|
+
if resp.status_code == 204:
|
|
257
|
+
return None
|
|
258
|
+
return resp.json()
|
|
259
|
+
|
|
260
|
+
def delete(
|
|
261
|
+
self, path: str, *, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
|
|
262
|
+
) -> Any:
|
|
263
|
+
resp = self.request("DELETE", path, params=params, requires_auth=requires_auth)
|
|
264
|
+
if resp.status_code == 204:
|
|
265
|
+
return None
|
|
266
|
+
return resp.json()
|
|
267
|
+
|
|
268
|
+
def get_raw(
|
|
269
|
+
self, path: str, *, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
|
|
270
|
+
) -> httpx.Response:
|
|
271
|
+
"""Return the raw httpx.Response (for CSV export, etc.)."""
|
|
272
|
+
return self.request("GET", path, params=params, requires_auth=requires_auth)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
276
|
+
# Async HTTP Client
|
|
277
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class AsyncHttpClient:
|
|
281
|
+
"""Asynchronous HTTP client wrapping httpx.AsyncClient."""
|
|
282
|
+
|
|
283
|
+
def __init__(
|
|
284
|
+
self,
|
|
285
|
+
base_url: str,
|
|
286
|
+
token_manager: AsyncTokenManager,
|
|
287
|
+
timeout: float = 30.0,
|
|
288
|
+
) -> None:
|
|
289
|
+
self._base_url = base_url.rstrip("/")
|
|
290
|
+
self._token_manager = token_manager
|
|
291
|
+
self._client = httpx.AsyncClient(
|
|
292
|
+
base_url=self._base_url,
|
|
293
|
+
timeout=timeout,
|
|
294
|
+
headers={"User-Agent": USER_AGENT},
|
|
295
|
+
follow_redirects=False, # Prevent HTTPS→HTTP credential leakage
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
async def close(self) -> None:
|
|
299
|
+
await self._client.aclose()
|
|
300
|
+
|
|
301
|
+
async def _build_headers(
|
|
302
|
+
self, extra: Optional[Dict[str, str]] = None, *, requires_auth: bool = True,
|
|
303
|
+
) -> Dict[str, str]:
|
|
304
|
+
headers: Dict[str, str] = {}
|
|
305
|
+
# Only attach Authorization header when auth is required.
|
|
306
|
+
if requires_auth:
|
|
307
|
+
token = await self._token_manager.get_valid_token()
|
|
308
|
+
if token:
|
|
309
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
310
|
+
if extra:
|
|
311
|
+
headers.update(extra)
|
|
312
|
+
return headers
|
|
313
|
+
|
|
314
|
+
async def request(
|
|
315
|
+
self,
|
|
316
|
+
method: str,
|
|
317
|
+
path: str,
|
|
318
|
+
*,
|
|
319
|
+
json: Any = None,
|
|
320
|
+
params: Optional[Dict[str, Any]] = None,
|
|
321
|
+
headers: Optional[Dict[str, str]] = None,
|
|
322
|
+
requires_auth: bool = True,
|
|
323
|
+
) -> httpx.Response:
|
|
324
|
+
"""Execute an HTTP request with auto-retry on 401 (token refresh) and 429."""
|
|
325
|
+
merged_headers = await self._build_headers(headers, requires_auth=requires_auth)
|
|
326
|
+
try:
|
|
327
|
+
response = await self._client.request(
|
|
328
|
+
method,
|
|
329
|
+
path,
|
|
330
|
+
json=json,
|
|
331
|
+
params=params,
|
|
332
|
+
headers=merged_headers,
|
|
333
|
+
)
|
|
334
|
+
except httpx.HTTPError as exc:
|
|
335
|
+
raise NetworkError(str(exc)) from exc
|
|
336
|
+
|
|
337
|
+
# Response size limit — check Content-Length header if available
|
|
338
|
+
content_length = response.headers.get("content-length")
|
|
339
|
+
if content_length:
|
|
340
|
+
try:
|
|
341
|
+
if int(content_length) > MAX_RESPONSE_BYTES:
|
|
342
|
+
raise NetworkError(f"Response too large: {content_length} bytes")
|
|
343
|
+
except ValueError:
|
|
344
|
+
pass # Malformed Content-Length header — skip size check
|
|
345
|
+
|
|
346
|
+
# Also check actual body size (chunked transfer encoding may omit Content-Length)
|
|
347
|
+
if len(response.content) > MAX_RESPONSE_BYTES:
|
|
348
|
+
raise NetworkError(f"Response body too large: {len(response.content)} bytes")
|
|
349
|
+
|
|
350
|
+
# Auto-retry once on 401 after force-refreshing the token
|
|
351
|
+
if response.status_code == 401 and requires_auth and self._token_manager.has_refresh_token:
|
|
352
|
+
await self._token_manager.force_refresh()
|
|
353
|
+
merged_headers = await self._build_headers(headers, requires_auth=requires_auth)
|
|
354
|
+
try:
|
|
355
|
+
response = await self._client.request(
|
|
356
|
+
method,
|
|
357
|
+
path,
|
|
358
|
+
json=json,
|
|
359
|
+
params=params,
|
|
360
|
+
headers=merged_headers,
|
|
361
|
+
)
|
|
362
|
+
except httpx.HTTPError as exc:
|
|
363
|
+
raise NetworkError(str(exc)) from exc
|
|
364
|
+
# Size check on 401-retry response
|
|
365
|
+
if len(response.content) > MAX_RESPONSE_BYTES:
|
|
366
|
+
raise NetworkError(f"Response body too large: {len(response.content)} bytes")
|
|
367
|
+
|
|
368
|
+
# Auto-retry once on 429
|
|
369
|
+
if response.status_code == 429:
|
|
370
|
+
wait = _parse_retry_after(response)
|
|
371
|
+
await asyncio.sleep(_add_jitter(min(wait, 10.0)))
|
|
372
|
+
# Rebuild headers to get fresh token in case it expired during wait
|
|
373
|
+
merged_headers = await self._build_headers(headers, requires_auth=requires_auth)
|
|
374
|
+
try:
|
|
375
|
+
response = await self._client.request(
|
|
376
|
+
method,
|
|
377
|
+
path,
|
|
378
|
+
json=json,
|
|
379
|
+
params=params,
|
|
380
|
+
headers=merged_headers,
|
|
381
|
+
)
|
|
382
|
+
except httpx.HTTPError as exc:
|
|
383
|
+
raise NetworkError(str(exc)) from exc
|
|
384
|
+
# Size check on 429-retry response
|
|
385
|
+
if len(response.content) > MAX_RESPONSE_BYTES:
|
|
386
|
+
raise NetworkError(f"Response body too large: {len(response.content)} bytes")
|
|
387
|
+
|
|
388
|
+
_raise_for_status(response)
|
|
389
|
+
return response
|
|
390
|
+
|
|
391
|
+
async def get(
|
|
392
|
+
self, path: str, *, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
|
|
393
|
+
) -> Any:
|
|
394
|
+
resp = await self.request("GET", path, params=params, requires_auth=requires_auth)
|
|
395
|
+
if resp.status_code == 204:
|
|
396
|
+
return None
|
|
397
|
+
return resp.json()
|
|
398
|
+
|
|
399
|
+
async def post(
|
|
400
|
+
self, path: str, *, json: Any = None, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
|
|
401
|
+
) -> Any:
|
|
402
|
+
resp = await self.request("POST", path, json=json, params=params, requires_auth=requires_auth)
|
|
403
|
+
if resp.status_code == 204:
|
|
404
|
+
return None
|
|
405
|
+
return resp.json()
|
|
406
|
+
|
|
407
|
+
async def put(
|
|
408
|
+
self, path: str, *, json: Any = None, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
|
|
409
|
+
) -> Any:
|
|
410
|
+
resp = await self.request("PUT", path, json=json, params=params, requires_auth=requires_auth)
|
|
411
|
+
if resp.status_code == 204:
|
|
412
|
+
return None
|
|
413
|
+
return resp.json()
|
|
414
|
+
|
|
415
|
+
async def patch(
|
|
416
|
+
self, path: str, *, json: Any = None, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
|
|
417
|
+
) -> Any:
|
|
418
|
+
resp = await self.request("PATCH", path, json=json, params=params, requires_auth=requires_auth)
|
|
419
|
+
if resp.status_code == 204:
|
|
420
|
+
return None
|
|
421
|
+
return resp.json()
|
|
422
|
+
|
|
423
|
+
async def delete(
|
|
424
|
+
self, path: str, *, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
|
|
425
|
+
) -> Any:
|
|
426
|
+
resp = await self.request("DELETE", path, params=params, requires_auth=requires_auth)
|
|
427
|
+
if resp.status_code == 204:
|
|
428
|
+
return None
|
|
429
|
+
return resp.json()
|
|
430
|
+
|
|
431
|
+
async def get_raw(
|
|
432
|
+
self, path: str, *, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
|
|
433
|
+
) -> httpx.Response:
|
|
434
|
+
"""Return the raw httpx.Response (for CSV export, etc.)."""
|
|
435
|
+
return await self.request("GET", path, params=params, requires_auth=requires_auth)
|
dominusnode/keys.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""API key management resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from .http_client import AsyncHttpClient, SyncHttpClient
|
|
9
|
+
from .types import ApiKey, CreatedApiKey
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
13
|
+
# Sync
|
|
14
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class KeysResource:
|
|
18
|
+
"""Synchronous API key operations."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, http: SyncHttpClient) -> None:
|
|
21
|
+
self._http = http
|
|
22
|
+
|
|
23
|
+
def create(self, label: str = "Default") -> CreatedApiKey:
|
|
24
|
+
"""Create a new API key. The raw key is only returned once."""
|
|
25
|
+
data = self._http.post("/api/keys", json={"label": label})
|
|
26
|
+
return CreatedApiKey(
|
|
27
|
+
key=data["key"],
|
|
28
|
+
id=data["id"],
|
|
29
|
+
prefix=data["prefix"],
|
|
30
|
+
label=data["label"],
|
|
31
|
+
created_at=str(data["created_at"]),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def list(self) -> List[ApiKey]:
|
|
35
|
+
"""List all API keys for the current user."""
|
|
36
|
+
data = self._http.get("/api/keys")
|
|
37
|
+
return [
|
|
38
|
+
ApiKey(
|
|
39
|
+
id=k["id"],
|
|
40
|
+
prefix=k["prefix"],
|
|
41
|
+
label=k["label"],
|
|
42
|
+
created_at=str(k["created_at"]),
|
|
43
|
+
revoked_at=str(k["revoked_at"]) if k.get("revoked_at") else None,
|
|
44
|
+
)
|
|
45
|
+
for k in data.get("keys", [])
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
def revoke(self, key_id: str) -> None:
|
|
49
|
+
"""Revoke an API key by ID."""
|
|
50
|
+
self._http.delete(f"/api/keys/{quote(key_id, safe='')}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
54
|
+
# Async
|
|
55
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AsyncKeysResource:
|
|
59
|
+
"""Asynchronous API key operations."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
62
|
+
self._http = http
|
|
63
|
+
|
|
64
|
+
async def create(self, label: str = "Default") -> CreatedApiKey:
|
|
65
|
+
data = await self._http.post("/api/keys", json={"label": label})
|
|
66
|
+
return CreatedApiKey(
|
|
67
|
+
key=data["key"],
|
|
68
|
+
id=data["id"],
|
|
69
|
+
prefix=data["prefix"],
|
|
70
|
+
label=data["label"],
|
|
71
|
+
created_at=str(data["created_at"]),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
async def list(self) -> List[ApiKey]:
|
|
75
|
+
data = await self._http.get("/api/keys")
|
|
76
|
+
return [
|
|
77
|
+
ApiKey(
|
|
78
|
+
id=k["id"],
|
|
79
|
+
prefix=k["prefix"],
|
|
80
|
+
label=k["label"],
|
|
81
|
+
created_at=str(k["created_at"]),
|
|
82
|
+
revoked_at=str(k["revoked_at"]) if k.get("revoked_at") else None,
|
|
83
|
+
)
|
|
84
|
+
for k in data.get("keys", [])
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
async def revoke(self, key_id: str) -> None:
|
|
88
|
+
await self._http.delete(f"/api/keys/{quote(key_id, safe='')}")
|
dominusnode/plans.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Plans resource -- list plans, get/change user plan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from .http_client import AsyncHttpClient, SyncHttpClient
|
|
8
|
+
from .types import Plan, UserPlanInfo, UserPlanUsage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _parse_plan(p: dict) -> Plan:
|
|
12
|
+
return Plan(
|
|
13
|
+
id=p["id"],
|
|
14
|
+
name=p["name"],
|
|
15
|
+
price_per_gb_cents=p["pricePerGbCents"],
|
|
16
|
+
price_per_gb_usd=p["pricePerGbUsd"],
|
|
17
|
+
monthly_bandwidth_bytes=p.get("monthlyBandwidthBytes", 0),
|
|
18
|
+
monthly_bandwidth_gb=p.get("monthlyBandwidthGB"),
|
|
19
|
+
is_default=p.get("isDefault", False),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
24
|
+
# Sync
|
|
25
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PlansResource:
|
|
29
|
+
"""Synchronous plans operations."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, http: SyncHttpClient) -> None:
|
|
32
|
+
self._http = http
|
|
33
|
+
|
|
34
|
+
def list(self) -> List[Plan]:
|
|
35
|
+
"""List all available plans."""
|
|
36
|
+
data = self._http.get("/api/plans")
|
|
37
|
+
return [_parse_plan(p) for p in data.get("plans", [])]
|
|
38
|
+
|
|
39
|
+
def get_user_plan(self) -> UserPlanInfo:
|
|
40
|
+
"""Get the current user's plan and monthly usage."""
|
|
41
|
+
data = self._http.get("/api/plans/user/plan")
|
|
42
|
+
plan = _parse_plan(data["plan"])
|
|
43
|
+
u = data["usage"]
|
|
44
|
+
usage = UserPlanUsage(
|
|
45
|
+
monthly_usage_bytes=u["monthlyUsageBytes"],
|
|
46
|
+
monthly_usage_gb=u["monthlyUsageGB"],
|
|
47
|
+
limit_bytes=u["limitBytes"],
|
|
48
|
+
limit_gb=u.get("limitGB"),
|
|
49
|
+
percent_used=u.get("percentUsed"),
|
|
50
|
+
)
|
|
51
|
+
return UserPlanInfo(plan=plan, usage=usage)
|
|
52
|
+
|
|
53
|
+
def change_plan(self, plan_id: str) -> Plan:
|
|
54
|
+
"""Change the current user's plan.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
plan_id: The plan ID to switch to (e.g. 'payg', 'vol100', 'vol1tb').
|
|
58
|
+
"""
|
|
59
|
+
data = self._http.put("/api/plans/user/plan", json={"planId": plan_id})
|
|
60
|
+
return _parse_plan(data["plan"])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
64
|
+
# Async
|
|
65
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AsyncPlansResource:
|
|
69
|
+
"""Asynchronous plans operations."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
72
|
+
self._http = http
|
|
73
|
+
|
|
74
|
+
async def list(self) -> List[Plan]:
|
|
75
|
+
data = await self._http.get("/api/plans")
|
|
76
|
+
return [_parse_plan(p) for p in data.get("plans", [])]
|
|
77
|
+
|
|
78
|
+
async def get_user_plan(self) -> UserPlanInfo:
|
|
79
|
+
data = await self._http.get("/api/plans/user/plan")
|
|
80
|
+
plan = _parse_plan(data["plan"])
|
|
81
|
+
u = data["usage"]
|
|
82
|
+
usage = UserPlanUsage(
|
|
83
|
+
monthly_usage_bytes=u["monthlyUsageBytes"],
|
|
84
|
+
monthly_usage_gb=u["monthlyUsageGB"],
|
|
85
|
+
limit_bytes=u["limitBytes"],
|
|
86
|
+
limit_gb=u.get("limitGB"),
|
|
87
|
+
percent_used=u.get("percentUsed"),
|
|
88
|
+
)
|
|
89
|
+
return UserPlanInfo(plan=plan, usage=usage)
|
|
90
|
+
|
|
91
|
+
async def change_plan(self, plan_id: str) -> Plan:
|
|
92
|
+
data = await self._http.put("/api/plans/user/plan", json={"planId": plan_id})
|
|
93
|
+
return _parse_plan(data["plan"])
|