intelion-cloud 0.2.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,82 @@
1
+ """Intelion Cloud Python client.
2
+
3
+ Usage::
4
+
5
+ from intelion_cloud import IntelionCloud
6
+
7
+ client = IntelionCloud(token="your_api_token")
8
+ servers = client.cloud_servers.list()
9
+ """
10
+
11
+ from ._client import AsyncIntelionCloud, IntelionCloud
12
+ from .constants import BillingPeriod, PricePlan, ServerState
13
+ from .constants import ServerStatus as ServerStatusEnum
14
+ from .exceptions import (
15
+ APIError,
16
+ AuthenticationError,
17
+ ConflictError,
18
+ ConnectionError,
19
+ ForbiddenError,
20
+ IntelionCloudError,
21
+ NotFoundError,
22
+ RateLimitError,
23
+ ServerError,
24
+ ValidationError,
25
+ )
26
+ from .models import (
27
+ CPU,
28
+ GPU,
29
+ OSImage,
30
+ RAM,
31
+ SSD,
32
+ CloudServer,
33
+ DebtInfo,
34
+ Flavor,
35
+ PhysicalServer,
36
+ Promocode,
37
+ ServerStatus,
38
+ SoftwareAddonInstance,
39
+ UsageAct,
40
+ User,
41
+ WhiteIP,
42
+ )
43
+
44
+ __version__ = "0.2.0"
45
+
46
+ __all__ = [
47
+ # Clients
48
+ "IntelionCloud",
49
+ "AsyncIntelionCloud",
50
+ # Models
51
+ "CloudServer",
52
+ "ServerStatus",
53
+ "Flavor",
54
+ "User",
55
+ "GPU",
56
+ "CPU",
57
+ "RAM",
58
+ "SSD",
59
+ "OSImage",
60
+ "UsageAct",
61
+ "DebtInfo",
62
+ "Promocode",
63
+ "WhiteIP",
64
+ "PhysicalServer",
65
+ "SoftwareAddonInstance",
66
+ # Constants
67
+ "ServerStatusEnum",
68
+ "ServerState",
69
+ "PricePlan",
70
+ "BillingPeriod",
71
+ # Exceptions
72
+ "IntelionCloudError",
73
+ "APIError",
74
+ "AuthenticationError",
75
+ "ForbiddenError",
76
+ "NotFoundError",
77
+ "ConflictError",
78
+ "RateLimitError",
79
+ "ValidationError",
80
+ "ServerError",
81
+ "ConnectionError",
82
+ ]
@@ -0,0 +1,108 @@
1
+ """Main client classes: IntelionCloud (sync) and AsyncIntelionCloud (async)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from .constants import DEFAULT_BASE_URL, DEFAULT_CONNECT_TIMEOUT, DEFAULT_TIMEOUT
8
+ from ._transport import AsyncTransport, SyncTransport
9
+ from .resources.cloud_servers import AsyncCloudServers, CloudServers
10
+ from .resources.flavors import AsyncFlavors, Flavors
11
+ from .resources.os_images import AsyncOSImages, OSImages
12
+ from .resources.users import AsyncUsers, Users
13
+
14
+
15
+ class IntelionCloud:
16
+ """Synchronous client for the Intelion Cloud API.
17
+
18
+ Usage::
19
+
20
+ client = IntelionCloud(token="your_api_token")
21
+ servers = client.cloud_servers.list()
22
+ client.close()
23
+
24
+ Or as a context manager::
25
+
26
+ with IntelionCloud(token="your_api_token") as client:
27
+ servers = client.cloud_servers.list()
28
+
29
+ Args:
30
+ token: API authentication token (``Authorization: Token <token>``).
31
+ base_url: Base URL of the Intelion Cloud instance.
32
+ timeout: Overall request timeout in seconds.
33
+ connect_timeout: Connection establishment timeout in seconds.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ token: str,
39
+ *,
40
+ base_url: str = DEFAULT_BASE_URL,
41
+ timeout: float = DEFAULT_TIMEOUT,
42
+ connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
43
+ ) -> None:
44
+ self._transport = SyncTransport(
45
+ token=token,
46
+ base_url=base_url,
47
+ timeout=timeout,
48
+ connect_timeout=connect_timeout,
49
+ )
50
+ self.cloud_servers = CloudServers(self._transport)
51
+ self.flavors = Flavors(self._transport)
52
+ self.os_images = OSImages(self._transport)
53
+ self.users = Users(self._transport)
54
+
55
+ def close(self) -> None:
56
+ """Close the underlying HTTP connection pool."""
57
+ self._transport.close()
58
+
59
+ def __enter__(self) -> IntelionCloud:
60
+ return self
61
+
62
+ def __exit__(self, *args: object) -> None:
63
+ self.close()
64
+
65
+
66
+ class AsyncIntelionCloud:
67
+ """Asynchronous client for the Intelion Cloud API.
68
+
69
+ Usage::
70
+
71
+ async with AsyncIntelionCloud(token="your_api_token") as client:
72
+ servers = await client.cloud_servers.list()
73
+
74
+ Args:
75
+ token: API authentication token (``Authorization: Token <token>``).
76
+ base_url: Base URL of the Intelion Cloud instance.
77
+ timeout: Overall request timeout in seconds.
78
+ connect_timeout: Connection establishment timeout in seconds.
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ token: str,
84
+ *,
85
+ base_url: str = DEFAULT_BASE_URL,
86
+ timeout: float = DEFAULT_TIMEOUT,
87
+ connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
88
+ ) -> None:
89
+ self._transport = AsyncTransport(
90
+ token=token,
91
+ base_url=base_url,
92
+ timeout=timeout,
93
+ connect_timeout=connect_timeout,
94
+ )
95
+ self.cloud_servers = AsyncCloudServers(self._transport)
96
+ self.flavors = AsyncFlavors(self._transport)
97
+ self.os_images = AsyncOSImages(self._transport)
98
+ self.users = AsyncUsers(self._transport)
99
+
100
+ async def close(self) -> None:
101
+ """Close the underlying async HTTP connection pool."""
102
+ await self._transport.close()
103
+
104
+ async def __aenter__(self) -> AsyncIntelionCloud:
105
+ return self
106
+
107
+ async def __aexit__(self, *args: object) -> None:
108
+ await self.close()
@@ -0,0 +1,66 @@
1
+ """Pagination helpers for DRF paginated responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Dict, Generic, List, Optional, Tuple, TypeVar
7
+ from urllib.parse import parse_qs, urlparse
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ @dataclass
13
+ class PaginatedResponse(Generic[T]):
14
+ """A single page of results from the API."""
15
+
16
+ count: int
17
+ results: List[T]
18
+ next_url: Optional[str] = None
19
+ previous_url: Optional[str] = None
20
+
21
+ @property
22
+ def has_next(self) -> bool:
23
+ return self.next_url is not None
24
+
25
+
26
+ def parse_paginated(data: Dict[str, Any]) -> Dict[str, Any]:
27
+ """Extract pagination metadata from a DRF response.
28
+
29
+ Returns dict with 'count', 'next', 'previous', 'results'.
30
+ If the response is not paginated (plain list), wraps it.
31
+ """
32
+ if isinstance(data, list):
33
+ return {"count": len(data), "next": None, "previous": None, "results": data}
34
+ return {
35
+ "count": data.get("count", 0),
36
+ "next": data.get("next"),
37
+ "previous": data.get("previous"),
38
+ "results": data.get("results", []),
39
+ }
40
+
41
+
42
+ def extract_next_page(url: Optional[str]) -> Optional[Tuple[str, Dict[str, str]]]:
43
+ """Convert an absolute next/previous URL to (path, params) for the transport.
44
+
45
+ Returns None if URL is None, otherwise (relative_path, query_params).
46
+
47
+ Example:
48
+ 'https://intelion.cloud/api/v2/flavors/?page=2'
49
+ -> ('flavors/', {'page': '2'})
50
+ """
51
+ if url is None:
52
+ return None
53
+
54
+ parsed = urlparse(url)
55
+
56
+ # Extract relative path after /api/v2/
57
+ path = parsed.path
58
+ marker = "/api/v2/"
59
+ idx = path.find(marker)
60
+ if idx >= 0:
61
+ path = path[idx + len(marker) :]
62
+
63
+ # Parse query string into flat dict (take first value of each key)
64
+ params = {k: v[0] for k, v in parse_qs(parsed.query).items()}
65
+
66
+ return path, params
@@ -0,0 +1,342 @@
1
+ """HTTP transport layer with retry logic, error mapping, and logging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from typing import Any, Dict, Optional
8
+
9
+ import httpx
10
+
11
+ from .constants import DEFAULT_BASE_URL, DEFAULT_CONNECT_TIMEOUT, DEFAULT_TIMEOUT
12
+ from .exceptions import (
13
+ APIError,
14
+ AuthenticationError,
15
+ ConnectionError,
16
+ ConflictError,
17
+ ForbiddenError,
18
+ NotFoundError,
19
+ RateLimitError,
20
+ ServerError,
21
+ ValidationError,
22
+ )
23
+
24
+ logger = logging.getLogger("intelion_cloud")
25
+
26
+ _IDEMPOTENT_METHODS = frozenset({"GET", "PUT", "PATCH", "DELETE"})
27
+
28
+ # Retry configuration
29
+ _MAX_RATE_LIMIT_RETRIES = 3
30
+ _MAX_SERVER_ERROR_RETRIES = 1
31
+ _MAX_CONNECTION_RETRIES = 2
32
+ _RATE_LIMIT_BASE_DELAY = 1.0
33
+ _SERVER_ERROR_DELAY = 1.0
34
+ _CONNECTION_BASE_DELAY = 0.5
35
+
36
+
37
+ def _build_api_url(base_url: str) -> str:
38
+ return base_url.rstrip("/") + "/api/v2/"
39
+
40
+
41
+ def _raise_for_status(response: httpx.Response) -> None:
42
+ """Map HTTP error responses to typed exceptions."""
43
+ status = response.status_code
44
+ if status < 400:
45
+ return
46
+
47
+ try:
48
+ body = response.json()
49
+ except Exception:
50
+ body = response.text
51
+
52
+ message = _extract_message(body, status)
53
+
54
+ if status == 400:
55
+ field_errors = body if isinstance(body, dict) else {}
56
+ raise ValidationError(
57
+ message,
58
+ status_code=status,
59
+ response_body=body,
60
+ field_errors=field_errors,
61
+ )
62
+ if status == 401:
63
+ raise AuthenticationError(message, status_code=status, response_body=body)
64
+ if status == 403:
65
+ raise ForbiddenError(message, status_code=status, response_body=body)
66
+ if status == 404:
67
+ raise NotFoundError(message, status_code=status, response_body=body)
68
+ if status == 409:
69
+ raise ConflictError(message, status_code=status, response_body=body)
70
+ if status == 429:
71
+ retry_after = _parse_retry_after(response)
72
+ raise RateLimitError(
73
+ message,
74
+ status_code=status,
75
+ response_body=body,
76
+ retry_after=retry_after,
77
+ )
78
+ if status >= 500:
79
+ raise ServerError(message, status_code=status, response_body=body)
80
+
81
+ raise APIError(message, status_code=status, response_body=body)
82
+
83
+
84
+ def _extract_message(body: Any, status_code: int) -> str:
85
+ if isinstance(body, dict):
86
+ for key in ("detail", "message", "error", "non_field_errors"):
87
+ if key in body:
88
+ val = body[key]
89
+ if isinstance(val, list):
90
+ return "; ".join(str(v) for v in val)
91
+ return str(val)
92
+ if isinstance(body, str) and body:
93
+ return body
94
+ return f"API error {status_code}"
95
+
96
+
97
+ def _parse_retry_after(response: httpx.Response) -> Optional[float]:
98
+ header = response.headers.get("retry-after")
99
+ if header is None:
100
+ return None
101
+ try:
102
+ return float(header)
103
+ except (ValueError, TypeError):
104
+ return None
105
+
106
+
107
+ def _should_retry_rate_limit(attempt: int) -> bool:
108
+ return attempt < _MAX_RATE_LIMIT_RETRIES
109
+
110
+
111
+ def _should_retry_server_error(method: str, attempt: int) -> bool:
112
+ return attempt < _MAX_SERVER_ERROR_RETRIES and method in _IDEMPOTENT_METHODS
113
+
114
+
115
+ def _should_retry_connection(method: str, attempt: int) -> bool:
116
+ return attempt < _MAX_CONNECTION_RETRIES and method in _IDEMPOTENT_METHODS
117
+
118
+
119
+ def _rate_limit_delay(attempt: int, retry_after: Optional[float]) -> float:
120
+ if retry_after is not None and retry_after > 0:
121
+ return min(retry_after, 30.0)
122
+ return _RATE_LIMIT_BASE_DELAY * (2**attempt)
123
+
124
+
125
+ class SyncTransport:
126
+ """Synchronous HTTP transport using httpx.Client."""
127
+
128
+ def __init__(
129
+ self,
130
+ token: str,
131
+ base_url: str = DEFAULT_BASE_URL,
132
+ timeout: float = DEFAULT_TIMEOUT,
133
+ connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
134
+ ) -> None:
135
+ self._client = httpx.Client(
136
+ base_url=_build_api_url(base_url),
137
+ headers={
138
+ "Authorization": f"Token {token}",
139
+ "Accept": "application/json",
140
+ },
141
+ timeout=httpx.Timeout(timeout, connect=connect_timeout),
142
+ )
143
+
144
+ def request(
145
+ self,
146
+ method: str,
147
+ path: str,
148
+ *,
149
+ json: Optional[Dict[str, Any]] = None,
150
+ params: Optional[Dict[str, Any]] = None,
151
+ ) -> httpx.Response:
152
+ attempt = 0
153
+ while True:
154
+ start = time.monotonic()
155
+ try:
156
+ response = self._client.request(
157
+ method,
158
+ path,
159
+ json=json,
160
+ params=params,
161
+ )
162
+ duration = time.monotonic() - start
163
+ logger.debug(
164
+ "%s %s -> %d (%.2fs)",
165
+ method,
166
+ path,
167
+ response.status_code,
168
+ duration,
169
+ )
170
+
171
+ try:
172
+ _raise_for_status(response)
173
+ except RateLimitError as exc:
174
+ if _should_retry_rate_limit(attempt):
175
+ delay = _rate_limit_delay(attempt, exc.retry_after)
176
+ logger.warning(
177
+ "Rate limited, retry %d/%d in %.1fs",
178
+ attempt + 1,
179
+ _MAX_RATE_LIMIT_RETRIES,
180
+ delay,
181
+ )
182
+ time.sleep(delay)
183
+ attempt += 1
184
+ continue
185
+ raise
186
+ except ServerError:
187
+ if _should_retry_server_error(method, attempt):
188
+ logger.warning(
189
+ "Server error %d, retry %d/%d in %.1fs",
190
+ response.status_code,
191
+ attempt + 1,
192
+ _MAX_SERVER_ERROR_RETRIES,
193
+ _SERVER_ERROR_DELAY,
194
+ )
195
+ time.sleep(_SERVER_ERROR_DELAY)
196
+ attempt += 1
197
+ continue
198
+ raise
199
+
200
+ return response
201
+
202
+ except (httpx.ConnectError, httpx.ReadError, httpx.WriteError) as exc:
203
+ if _should_retry_connection(method, attempt):
204
+ delay = _CONNECTION_BASE_DELAY * (2**attempt)
205
+ logger.warning(
206
+ "Connection error (%s), retry %d/%d in %.1fs",
207
+ type(exc).__name__,
208
+ attempt + 1,
209
+ _MAX_CONNECTION_RETRIES,
210
+ delay,
211
+ )
212
+ time.sleep(delay)
213
+ attempt += 1
214
+ continue
215
+ raise ConnectionError(str(exc)) from exc
216
+ except httpx.TimeoutException as exc:
217
+ if method == "GET" and attempt < _MAX_CONNECTION_RETRIES:
218
+ delay = _RATE_LIMIT_BASE_DELAY * (2**attempt)
219
+ logger.warning(
220
+ "Timeout on GET, retry %d/%d in %.1fs",
221
+ attempt + 1,
222
+ _MAX_CONNECTION_RETRIES,
223
+ delay,
224
+ )
225
+ time.sleep(delay)
226
+ attempt += 1
227
+ continue
228
+ raise ConnectionError(str(exc)) from exc
229
+
230
+ def close(self) -> None:
231
+ self._client.close()
232
+
233
+
234
+ class AsyncTransport:
235
+ """Asynchronous HTTP transport using httpx.AsyncClient."""
236
+
237
+ def __init__(
238
+ self,
239
+ token: str,
240
+ base_url: str = DEFAULT_BASE_URL,
241
+ timeout: float = DEFAULT_TIMEOUT,
242
+ connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
243
+ ) -> None:
244
+ self._client = httpx.AsyncClient(
245
+ base_url=_build_api_url(base_url),
246
+ headers={
247
+ "Authorization": f"Token {token}",
248
+ "Accept": "application/json",
249
+ },
250
+ timeout=httpx.Timeout(timeout, connect=connect_timeout),
251
+ )
252
+
253
+ async def request(
254
+ self,
255
+ method: str,
256
+ path: str,
257
+ *,
258
+ json: Optional[Dict[str, Any]] = None,
259
+ params: Optional[Dict[str, Any]] = None,
260
+ ) -> httpx.Response:
261
+ import asyncio
262
+
263
+ attempt = 0
264
+ while True:
265
+ start = time.monotonic()
266
+ try:
267
+ response = await self._client.request(
268
+ method,
269
+ path,
270
+ json=json,
271
+ params=params,
272
+ )
273
+ duration = time.monotonic() - start
274
+ logger.debug(
275
+ "%s %s -> %d (%.2fs)",
276
+ method,
277
+ path,
278
+ response.status_code,
279
+ duration,
280
+ )
281
+
282
+ try:
283
+ _raise_for_status(response)
284
+ except RateLimitError as exc:
285
+ if _should_retry_rate_limit(attempt):
286
+ delay = _rate_limit_delay(attempt, exc.retry_after)
287
+ logger.warning(
288
+ "Rate limited, retry %d/%d in %.1fs",
289
+ attempt + 1,
290
+ _MAX_RATE_LIMIT_RETRIES,
291
+ delay,
292
+ )
293
+ await asyncio.sleep(delay)
294
+ attempt += 1
295
+ continue
296
+ raise
297
+ except ServerError:
298
+ if _should_retry_server_error(method, attempt):
299
+ logger.warning(
300
+ "Server error %d, retry %d/%d in %.1fs",
301
+ response.status_code,
302
+ attempt + 1,
303
+ _MAX_SERVER_ERROR_RETRIES,
304
+ _SERVER_ERROR_DELAY,
305
+ )
306
+ await asyncio.sleep(_SERVER_ERROR_DELAY)
307
+ attempt += 1
308
+ continue
309
+ raise
310
+
311
+ return response
312
+
313
+ except (httpx.ConnectError, httpx.ReadError, httpx.WriteError) as exc:
314
+ if _should_retry_connection(method, attempt):
315
+ delay = _CONNECTION_BASE_DELAY * (2**attempt)
316
+ logger.warning(
317
+ "Connection error (%s), retry %d/%d in %.1fs",
318
+ type(exc).__name__,
319
+ attempt + 1,
320
+ _MAX_CONNECTION_RETRIES,
321
+ delay,
322
+ )
323
+ await asyncio.sleep(delay)
324
+ attempt += 1
325
+ continue
326
+ raise ConnectionError(str(exc)) from exc
327
+ except httpx.TimeoutException as exc:
328
+ if method == "GET" and attempt < _MAX_CONNECTION_RETRIES:
329
+ delay = _RATE_LIMIT_BASE_DELAY * (2**attempt)
330
+ logger.warning(
331
+ "Timeout on GET, retry %d/%d in %.1fs",
332
+ attempt + 1,
333
+ _MAX_CONNECTION_RETRIES,
334
+ delay,
335
+ )
336
+ await asyncio.sleep(delay)
337
+ attempt += 1
338
+ continue
339
+ raise ConnectionError(str(exc)) from exc
340
+
341
+ async def close(self) -> None:
342
+ await self._client.aclose()
@@ -0,0 +1,74 @@
1
+ """Enumerations and constants matching the Intelion Cloud API."""
2
+
3
+ from enum import IntEnum
4
+
5
+ __all__ = [
6
+ "ServerStatus",
7
+ "ServerState",
8
+ "PricePlan",
9
+ "BillingPeriod",
10
+ "OSType",
11
+ "DEFAULT_BASE_URL",
12
+ "DEFAULT_TIMEOUT",
13
+ "DEFAULT_CONNECT_TIMEOUT",
14
+ ]
15
+
16
+ DEFAULT_BASE_URL = "https://intelion.cloud"
17
+ DEFAULT_TIMEOUT = 30.0
18
+ DEFAULT_CONNECT_TIMEOUT = 10.0
19
+
20
+
21
+ class ServerStatus(IntEnum):
22
+ """Server status codes returned by the API."""
23
+
24
+ ERROR = -4
25
+ DELETED = -3
26
+ REQUESTED = -2
27
+ PAUSED = -1
28
+ PAUSING = 0
29
+ START = 1
30
+ ACTIVE = 2
31
+ PREPARING = 3
32
+
33
+
34
+ class ServerState(IntEnum):
35
+ """Server state codes indicating current operation."""
36
+
37
+ IDLE = 0
38
+ STARTING = 100
39
+ SHELVING = 200
40
+ MIGRATING_SHELVING = 301
41
+ MIGRATING_SNAPSHOTTING = 302
42
+ MIGRATING_DELETING = 303
43
+ MIGRATING_CREATING = 304
44
+ CLONING = 400
45
+ QUEUED = 500
46
+ AWAITING_PASSWORD = 600
47
+ MAINTENANCE = 700
48
+
49
+
50
+ class PricePlan(IntEnum):
51
+ """Billing plan options for server configurations."""
52
+
53
+ POSTPAID_QUARTER = -3
54
+ POSTPAID_MONTHLY = -1
55
+ HOURLY = 0
56
+ MONTHLY = 1
57
+ QUARTERLY = 3
58
+ SEMIANNUAL = 6
59
+ ANNUAL = 12
60
+
61
+
62
+ class BillingPeriod(IntEnum):
63
+ """Billing period types used in usage acts."""
64
+
65
+ HOURLY = 0
66
+ MONTHLY = 30
67
+ MONTHLY_ALIGNED = 31
68
+
69
+
70
+ class OSType:
71
+ """Operating system type identifiers."""
72
+
73
+ WINDOWS = "win"
74
+ LINUX = "lin"