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 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)