impreza-sdk 0.3.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.
- impreza/__init__.py +171 -0
- impreza/_http.py +277 -0
- impreza/_http_async.py +270 -0
- impreza/_pagination.py +72 -0
- impreza/_polling.py +280 -0
- impreza/_topup.py +311 -0
- impreza/_tor.py +81 -0
- impreza/async_client.py +125 -0
- impreza/client.py +145 -0
- impreza/exceptions.py +235 -0
- impreza/models/__init__.py +85 -0
- impreza/models/account.py +97 -0
- impreza/models/dns.py +23 -0
- impreza/models/domain.py +68 -0
- impreza/models/email.py +24 -0
- impreza/models/invoice.py +60 -0
- impreza/models/order.py +69 -0
- impreza/models/product.py +116 -0
- impreza/models/service.py +36 -0
- impreza/models/tld.py +30 -0
- impreza/models/vps.py +30 -0
- impreza/models/vps_extras.py +140 -0
- impreza/models/webhook.py +104 -0
- impreza/py.typed +0 -0
- impreza/resources/__init__.py +96 -0
- impreza/resources/account.py +171 -0
- impreza/resources/catalog.py +139 -0
- impreza/resources/domains.py +454 -0
- impreza/resources/email.py +228 -0
- impreza/resources/hosting.py +92 -0
- impreza/resources/invoices.py +69 -0
- impreza/resources/orders.py +462 -0
- impreza/resources/services.py +136 -0
- impreza/resources/vps.py +924 -0
- impreza/resources/vps_cloud.py +313 -0
- impreza/resources/vps_proxmox.py +350 -0
- impreza/resources/webhooks.py +284 -0
- impreza/webhooks.py +143 -0
- impreza_sdk-0.3.0.dist-info/METADATA +311 -0
- impreza_sdk-0.3.0.dist-info/RECORD +42 -0
- impreza_sdk-0.3.0.dist-info/WHEEL +5 -0
- impreza_sdk-0.3.0.dist-info/top_level.txt +1 -0
impreza/__init__.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Impreza Host Python SDK.
|
|
2
|
+
|
|
3
|
+
Phase 1.4b-i alpha — sync :class:`Client` and async :class:`AsyncClient`,
|
|
4
|
+
both with auth, retry, error mapping, optional Tor routing, and the
|
|
5
|
+
following resources:
|
|
6
|
+
|
|
7
|
+
* ``account`` (with nested ``account.services``) — read
|
|
8
|
+
* ``catalog`` (products + groups + TLD pricing) — read
|
|
9
|
+
* ``invoices`` — read
|
|
10
|
+
* ``domains`` (with nested ``domains.dns``) — full CRUD (16 ops)
|
|
11
|
+
* ``vps`` — smart-dispatch over Proxmox + Cloud backends with the
|
|
12
|
+
common operation surface (power, hostname, password, reinstall,
|
|
13
|
+
status). Backend-specific ops (snapshots, backups, images, rescue,
|
|
14
|
+
ISO) land in 1.4b-ii.
|
|
15
|
+
|
|
16
|
+
>>> from impreza import Client
|
|
17
|
+
>>> with Client.from_env() as c:
|
|
18
|
+
... print(c.account.get().balance)
|
|
19
|
+
... for svc in c.account.services.list(status="Active"):
|
|
20
|
+
... print(svc.id, svc.product, svc.status)
|
|
21
|
+
... vps = c.vps.get(1234) # auto-dispatches Proxmox/Cloud
|
|
22
|
+
... print(vps.status().power_state)
|
|
23
|
+
... vps.reboot()
|
|
24
|
+
|
|
25
|
+
>>> import asyncio
|
|
26
|
+
>>> from impreza import AsyncClient
|
|
27
|
+
>>> async def main():
|
|
28
|
+
... async with AsyncClient.from_env() as c:
|
|
29
|
+
... for vps in await c.vps.list():
|
|
30
|
+
... print(vps.id, vps.backend)
|
|
31
|
+
>>> asyncio.run(main()) # doctest: +SKIP
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from ._polling import AsyncOperation, Operation
|
|
35
|
+
from ._topup import AsyncTopupInvoice, TopupInvoice
|
|
36
|
+
from .async_client import AsyncClient
|
|
37
|
+
from .client import Client
|
|
38
|
+
from .exceptions import (
|
|
39
|
+
ApiError,
|
|
40
|
+
AuthError,
|
|
41
|
+
BackendNotSupported,
|
|
42
|
+
ImprezaError,
|
|
43
|
+
InsufficientCredit,
|
|
44
|
+
InvalidRequest,
|
|
45
|
+
IpNotWhitelisted,
|
|
46
|
+
NetworkError,
|
|
47
|
+
OperationFailed,
|
|
48
|
+
OperationTimeout,
|
|
49
|
+
PermissionDenied,
|
|
50
|
+
RateLimitExceeded,
|
|
51
|
+
ResourceNotFound,
|
|
52
|
+
ServerError,
|
|
53
|
+
TopupFailed,
|
|
54
|
+
TopupTimeout,
|
|
55
|
+
UpstreamError,
|
|
56
|
+
WebhookSignatureMismatch,
|
|
57
|
+
)
|
|
58
|
+
from .models.account import (
|
|
59
|
+
AccountInfo,
|
|
60
|
+
IpWhitelistEntry,
|
|
61
|
+
KeyIdentity,
|
|
62
|
+
TopupInvoiceData,
|
|
63
|
+
)
|
|
64
|
+
from .models.dns import DnsRecord
|
|
65
|
+
from .models.domain import Domain, DomainRegistration, DomainTransfer
|
|
66
|
+
from .models.email import TitanSsoUrl
|
|
67
|
+
from .models.invoice import Invoice, InvoiceDetail, InvoiceItem, InvoiceTransaction
|
|
68
|
+
from .models.order import Order, OrderDetail, OrderItem, OrderResult
|
|
69
|
+
from .models.product import (
|
|
70
|
+
ConfigOption,
|
|
71
|
+
ConfigOptionChoice,
|
|
72
|
+
CustomField,
|
|
73
|
+
CyclePrice,
|
|
74
|
+
Product,
|
|
75
|
+
ProductDetail,
|
|
76
|
+
ProductGroup,
|
|
77
|
+
)
|
|
78
|
+
from .models.service import Service, VpsBackend
|
|
79
|
+
from .models.tld import TldPricing
|
|
80
|
+
from .models.vps import VpsStatus
|
|
81
|
+
from .models.vps_extras import (
|
|
82
|
+
Backup,
|
|
83
|
+
BackupSchedule,
|
|
84
|
+
ConsoleUrl,
|
|
85
|
+
Image,
|
|
86
|
+
Snapshot,
|
|
87
|
+
SshConsole,
|
|
88
|
+
SshKey,
|
|
89
|
+
VncCredentials,
|
|
90
|
+
VpsOperation,
|
|
91
|
+
)
|
|
92
|
+
from .models.webhook import (
|
|
93
|
+
WebhookDelivery,
|
|
94
|
+
WebhookEvent,
|
|
95
|
+
WebhookEventCatalog,
|
|
96
|
+
WebhookSubscription,
|
|
97
|
+
)
|
|
98
|
+
from .resources.vps import AsyncVps, Vps
|
|
99
|
+
|
|
100
|
+
__version__ = "0.1.0a0"
|
|
101
|
+
|
|
102
|
+
__all__ = [
|
|
103
|
+
"AccountInfo",
|
|
104
|
+
"ApiError",
|
|
105
|
+
"AsyncClient",
|
|
106
|
+
"AsyncOperation",
|
|
107
|
+
"AsyncTopupInvoice",
|
|
108
|
+
"AsyncVps",
|
|
109
|
+
"AuthError",
|
|
110
|
+
"Backup",
|
|
111
|
+
"BackupSchedule",
|
|
112
|
+
"BackendNotSupported",
|
|
113
|
+
"Client",
|
|
114
|
+
"ConfigOption",
|
|
115
|
+
"ConfigOptionChoice",
|
|
116
|
+
"ConsoleUrl",
|
|
117
|
+
"CustomField",
|
|
118
|
+
"CyclePrice",
|
|
119
|
+
"DnsRecord",
|
|
120
|
+
"Domain",
|
|
121
|
+
"DomainRegistration",
|
|
122
|
+
"DomainTransfer",
|
|
123
|
+
"Image",
|
|
124
|
+
"ImprezaError",
|
|
125
|
+
"InsufficientCredit",
|
|
126
|
+
"InvalidRequest",
|
|
127
|
+
"Invoice",
|
|
128
|
+
"InvoiceDetail",
|
|
129
|
+
"InvoiceItem",
|
|
130
|
+
"InvoiceTransaction",
|
|
131
|
+
"IpNotWhitelisted",
|
|
132
|
+
"IpWhitelistEntry",
|
|
133
|
+
"KeyIdentity",
|
|
134
|
+
"NetworkError",
|
|
135
|
+
"Operation",
|
|
136
|
+
"OperationFailed",
|
|
137
|
+
"OperationTimeout",
|
|
138
|
+
"Order",
|
|
139
|
+
"OrderDetail",
|
|
140
|
+
"OrderItem",
|
|
141
|
+
"OrderResult",
|
|
142
|
+
"PermissionDenied",
|
|
143
|
+
"Product",
|
|
144
|
+
"ProductDetail",
|
|
145
|
+
"ProductGroup",
|
|
146
|
+
"RateLimitExceeded",
|
|
147
|
+
"ResourceNotFound",
|
|
148
|
+
"Service",
|
|
149
|
+
"ServerError",
|
|
150
|
+
"Snapshot",
|
|
151
|
+
"SshConsole",
|
|
152
|
+
"SshKey",
|
|
153
|
+
"TitanSsoUrl",
|
|
154
|
+
"TldPricing",
|
|
155
|
+
"TopupFailed",
|
|
156
|
+
"TopupInvoice",
|
|
157
|
+
"TopupInvoiceData",
|
|
158
|
+
"TopupTimeout",
|
|
159
|
+
"UpstreamError",
|
|
160
|
+
"VncCredentials",
|
|
161
|
+
"Vps",
|
|
162
|
+
"VpsBackend",
|
|
163
|
+
"VpsOperation",
|
|
164
|
+
"VpsStatus",
|
|
165
|
+
"WebhookDelivery",
|
|
166
|
+
"WebhookEvent",
|
|
167
|
+
"WebhookEventCatalog",
|
|
168
|
+
"WebhookSignatureMismatch",
|
|
169
|
+
"WebhookSubscription",
|
|
170
|
+
"__version__",
|
|
171
|
+
]
|
impreza/_http.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Internal HTTP transport.
|
|
2
|
+
|
|
3
|
+
Not part of the public SDK surface — public consumers should never import
|
|
4
|
+
from this module. Resources delegate all network work here. The public
|
|
5
|
+
``Client`` wraps a single ``HttpClient`` instance.
|
|
6
|
+
|
|
7
|
+
Responsibilities:
|
|
8
|
+
|
|
9
|
+
* Inject ``X-API-Key`` and ``X-API-Secret`` on every request.
|
|
10
|
+
* Retry transient failures (5xx and 429) with exponential backoff and jitter.
|
|
11
|
+
* Honour the ``Retry-After`` header on 429 responses.
|
|
12
|
+
* Unwrap the success envelope (``{"success": true, "data": ..., "meta": ...}``)
|
|
13
|
+
and surface ``meta.request_id`` to callers via exceptions.
|
|
14
|
+
* Map status codes and ``error.code`` to typed exceptions from
|
|
15
|
+
:mod:`impreza.exceptions`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import random
|
|
21
|
+
import time
|
|
22
|
+
from types import TracebackType
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from .exceptions import (
|
|
28
|
+
ApiError,
|
|
29
|
+
AuthError,
|
|
30
|
+
InsufficientCredit,
|
|
31
|
+
InvalidRequest,
|
|
32
|
+
IpNotWhitelisted,
|
|
33
|
+
NetworkError,
|
|
34
|
+
PermissionDenied,
|
|
35
|
+
RateLimitExceeded,
|
|
36
|
+
ResourceNotFound,
|
|
37
|
+
ServerError,
|
|
38
|
+
UpstreamError,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
DEFAULT_BASE_URL = "https://api.imprezahost.com/v1"
|
|
42
|
+
DEFAULT_TIMEOUT = 30.0
|
|
43
|
+
DEFAULT_MAX_RETRIES = 3
|
|
44
|
+
USER_AGENT = "impreza-sdk-python/0.1.0a0"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class HttpClient:
|
|
48
|
+
"""Sync HTTP transport with auth, retry, and envelope handling."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
api_key: str,
|
|
53
|
+
api_secret: str,
|
|
54
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
55
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
56
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
57
|
+
proxy: str | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
self._max_retries = max_retries
|
|
60
|
+
self._client = httpx.Client(
|
|
61
|
+
base_url=base_url.rstrip("/"),
|
|
62
|
+
timeout=timeout,
|
|
63
|
+
headers={
|
|
64
|
+
"X-API-Key": api_key,
|
|
65
|
+
"X-API-Secret": api_secret,
|
|
66
|
+
"Accept": "application/json",
|
|
67
|
+
"User-Agent": USER_AGENT,
|
|
68
|
+
},
|
|
69
|
+
proxy=proxy,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def close(self) -> None:
|
|
73
|
+
self._client.close()
|
|
74
|
+
|
|
75
|
+
def __enter__(self) -> HttpClient:
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
def __exit__(
|
|
79
|
+
self,
|
|
80
|
+
exc_type: type[BaseException] | None,
|
|
81
|
+
exc: BaseException | None,
|
|
82
|
+
tb: TracebackType | None,
|
|
83
|
+
) -> None:
|
|
84
|
+
self.close()
|
|
85
|
+
|
|
86
|
+
# ── public methods ─────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
def get(
|
|
89
|
+
self,
|
|
90
|
+
path: str,
|
|
91
|
+
*,
|
|
92
|
+
params: dict[str, Any] | None = None,
|
|
93
|
+
) -> dict[str, Any]:
|
|
94
|
+
return self._request("GET", path, params=params)
|
|
95
|
+
|
|
96
|
+
def post(
|
|
97
|
+
self,
|
|
98
|
+
path: str,
|
|
99
|
+
*,
|
|
100
|
+
json: dict[str, Any] | None = None,
|
|
101
|
+
) -> dict[str, Any]:
|
|
102
|
+
return self._request("POST", path, json=json)
|
|
103
|
+
|
|
104
|
+
def patch(
|
|
105
|
+
self,
|
|
106
|
+
path: str,
|
|
107
|
+
*,
|
|
108
|
+
json: dict[str, Any] | None = None,
|
|
109
|
+
) -> dict[str, Any]:
|
|
110
|
+
return self._request("PATCH", path, json=json)
|
|
111
|
+
|
|
112
|
+
def put(
|
|
113
|
+
self,
|
|
114
|
+
path: str,
|
|
115
|
+
*,
|
|
116
|
+
json: dict[str, Any] | None = None,
|
|
117
|
+
) -> dict[str, Any]:
|
|
118
|
+
return self._request("PUT", path, json=json)
|
|
119
|
+
|
|
120
|
+
def delete(
|
|
121
|
+
self,
|
|
122
|
+
path: str,
|
|
123
|
+
*,
|
|
124
|
+
json: dict[str, Any] | None = None,
|
|
125
|
+
) -> dict[str, Any]:
|
|
126
|
+
return self._request("DELETE", path, json=json)
|
|
127
|
+
|
|
128
|
+
# ── internals ──────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
def _request(
|
|
131
|
+
self,
|
|
132
|
+
method: str,
|
|
133
|
+
path: str,
|
|
134
|
+
*,
|
|
135
|
+
params: dict[str, Any] | None = None,
|
|
136
|
+
json: dict[str, Any] | None = None,
|
|
137
|
+
) -> dict[str, Any]:
|
|
138
|
+
attempt = 0
|
|
139
|
+
last_network_error: httpx.RequestError | None = None
|
|
140
|
+
|
|
141
|
+
while attempt <= self._max_retries:
|
|
142
|
+
try:
|
|
143
|
+
response = self._client.request(method, path, params=params, json=json)
|
|
144
|
+
except httpx.RequestError as exc:
|
|
145
|
+
last_network_error = exc
|
|
146
|
+
if attempt >= self._max_retries:
|
|
147
|
+
raise NetworkError(
|
|
148
|
+
f"Could not reach the Impreza API: {exc}",
|
|
149
|
+
) from exc
|
|
150
|
+
self._sleep_backoff(attempt)
|
|
151
|
+
attempt += 1
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
if response.status_code < 400:
|
|
155
|
+
return self._unwrap(response)
|
|
156
|
+
|
|
157
|
+
if response.status_code == 429 and attempt < self._max_retries:
|
|
158
|
+
retry_after = self._parse_retry_after(response)
|
|
159
|
+
wait = retry_after if retry_after is not None else self._backoff(attempt)
|
|
160
|
+
time.sleep(wait)
|
|
161
|
+
attempt += 1
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
if 500 <= response.status_code < 600 and attempt < self._max_retries:
|
|
165
|
+
self._sleep_backoff(attempt)
|
|
166
|
+
attempt += 1
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
raise self._exception_from_response(response)
|
|
170
|
+
|
|
171
|
+
# Defensive: loop exited without return / raise. Should be unreachable
|
|
172
|
+
# in normal operation — kept so type checkers see all paths terminated.
|
|
173
|
+
if last_network_error is not None:
|
|
174
|
+
raise NetworkError(
|
|
175
|
+
f"Could not reach the Impreza API: {last_network_error}",
|
|
176
|
+
) from last_network_error
|
|
177
|
+
raise NetworkError("Exhausted retries without reaching the API")
|
|
178
|
+
|
|
179
|
+
# ── envelope ───────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
def _unwrap(self, response: httpx.Response) -> dict[str, Any]:
|
|
182
|
+
try:
|
|
183
|
+
payload = response.json()
|
|
184
|
+
except ValueError as exc:
|
|
185
|
+
raise ApiError(
|
|
186
|
+
f"Server returned non-JSON response (status {response.status_code})",
|
|
187
|
+
status_code=response.status_code,
|
|
188
|
+
) from exc
|
|
189
|
+
|
|
190
|
+
if not isinstance(payload, dict):
|
|
191
|
+
raise ApiError(
|
|
192
|
+
"Server response was not a JSON object",
|
|
193
|
+
status_code=response.status_code,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# 2xx with success=False shouldn't happen, but if it does, treat as error.
|
|
197
|
+
if payload.get("success") is False:
|
|
198
|
+
raise self._exception_from_payload(payload, response.status_code)
|
|
199
|
+
|
|
200
|
+
return payload
|
|
201
|
+
|
|
202
|
+
# ── retry helpers ──────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
def _parse_retry_after(self, response: httpx.Response) -> int | None:
|
|
205
|
+
raw = response.headers.get("Retry-After")
|
|
206
|
+
if raw is None:
|
|
207
|
+
return None
|
|
208
|
+
try:
|
|
209
|
+
return max(0, int(raw))
|
|
210
|
+
except ValueError:
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
def _backoff(self, attempt: int) -> float:
|
|
214
|
+
# Exponential with jitter: ~1s, ~2s, ~4s, ~8s ...
|
|
215
|
+
base = float(2**attempt)
|
|
216
|
+
return base + random.uniform(0, base * 0.5)
|
|
217
|
+
|
|
218
|
+
def _sleep_backoff(self, attempt: int) -> None:
|
|
219
|
+
time.sleep(self._backoff(attempt))
|
|
220
|
+
|
|
221
|
+
# ── exception mapping ──────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
def _exception_from_response(self, response: httpx.Response) -> ApiError:
|
|
224
|
+
try:
|
|
225
|
+
raw = response.json()
|
|
226
|
+
except ValueError:
|
|
227
|
+
raw = None
|
|
228
|
+
payload: dict[str, Any] = raw if isinstance(raw, dict) else {}
|
|
229
|
+
return self._exception_from_payload(payload, response.status_code, response)
|
|
230
|
+
|
|
231
|
+
def _exception_from_payload(
|
|
232
|
+
self,
|
|
233
|
+
payload: dict[str, Any],
|
|
234
|
+
status_code: int,
|
|
235
|
+
response: httpx.Response | None = None,
|
|
236
|
+
) -> ApiError:
|
|
237
|
+
error_block_raw = payload.get("error")
|
|
238
|
+
error_block: dict[str, Any] = error_block_raw if isinstance(error_block_raw, dict) else {}
|
|
239
|
+
meta_raw = payload.get("meta")
|
|
240
|
+
meta: dict[str, Any] = meta_raw if isinstance(meta_raw, dict) else {}
|
|
241
|
+
details_raw = error_block.get("details")
|
|
242
|
+
details: dict[str, Any] = details_raw if isinstance(details_raw, dict) else {}
|
|
243
|
+
|
|
244
|
+
code = error_block.get("code")
|
|
245
|
+
message = error_block.get("message") or f"HTTP {status_code}"
|
|
246
|
+
request_id = meta.get("request_id")
|
|
247
|
+
|
|
248
|
+
kwargs: dict[str, Any] = {
|
|
249
|
+
"code": code,
|
|
250
|
+
"request_id": request_id,
|
|
251
|
+
"status_code": status_code,
|
|
252
|
+
"details": details,
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if status_code == 401:
|
|
256
|
+
return AuthError(message, **kwargs)
|
|
257
|
+
if status_code == 403:
|
|
258
|
+
normalized = (code or "").lower() if isinstance(code, str) else ""
|
|
259
|
+
if normalized == "ip_not_whitelisted":
|
|
260
|
+
return IpNotWhitelisted(message, **kwargs)
|
|
261
|
+
return PermissionDenied(message, **kwargs)
|
|
262
|
+
if status_code == 404:
|
|
263
|
+
return ResourceNotFound(message, **kwargs)
|
|
264
|
+
if status_code == 400:
|
|
265
|
+
return InvalidRequest(message, **kwargs)
|
|
266
|
+
if status_code == 402:
|
|
267
|
+
return InsufficientCredit(message, **kwargs)
|
|
268
|
+
if status_code == 429:
|
|
269
|
+
retry_after: int | None = None
|
|
270
|
+
if response is not None:
|
|
271
|
+
retry_after = self._parse_retry_after(response)
|
|
272
|
+
return RateLimitExceeded(message, retry_after=retry_after, **kwargs)
|
|
273
|
+
if status_code in (502, 504):
|
|
274
|
+
return UpstreamError(message, **kwargs)
|
|
275
|
+
if 500 <= status_code < 600:
|
|
276
|
+
return ServerError(message, **kwargs)
|
|
277
|
+
return ApiError(message, **kwargs)
|