pantech 0.1.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.
pantech/__init__.py ADDED
@@ -0,0 +1,71 @@
1
+ """The official Python SDK for the Pantech Dynamics public API.
2
+
3
+ import pantech
4
+
5
+ client = pantech.Pantech() # reads PANTECH_API_KEY
6
+ order = client.instances.create(
7
+ name="web-1", plan_slug="starter", image_slug="ubuntu-24-04", ssh_key_id="sshk_…"
8
+ )
9
+ client.instances.wait_for_order(order["order_id"])
10
+
11
+ Docs: https://docs.pantechdynamics.com/sdks/python
12
+ """
13
+
14
+ from . import types
15
+ from ._base_client import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT
16
+ from ._client import AsyncPantech, Pantech
17
+ from ._exceptions import (
18
+ APIConnectionError,
19
+ APIError,
20
+ APITimeoutError,
21
+ AuthenticationError,
22
+ BadRequestError,
23
+ ConflictError,
24
+ FieldError,
25
+ InternalServerError,
26
+ NotFoundError,
27
+ OperationFailedError,
28
+ OrderFailedError,
29
+ PantechError,
30
+ PaymentRequiredError,
31
+ PermissionDeniedError,
32
+ RateLimitError,
33
+ ValidationError,
34
+ WaitTimeoutError,
35
+ WebhookVerificationError,
36
+ )
37
+ from ._types import NOT_GIVEN, NotGiven
38
+ from ._version import __version__
39
+ from ._webhooks import SIGNATURE_HEADER, verify_webhook
40
+
41
+ __all__ = [
42
+ "Pantech",
43
+ "AsyncPantech",
44
+ "verify_webhook",
45
+ "SIGNATURE_HEADER",
46
+ "types",
47
+ "NOT_GIVEN",
48
+ "NotGiven",
49
+ "DEFAULT_BASE_URL",
50
+ "DEFAULT_MAX_RETRIES",
51
+ "DEFAULT_TIMEOUT",
52
+ "PantechError",
53
+ "APIError",
54
+ "BadRequestError",
55
+ "AuthenticationError",
56
+ "PaymentRequiredError",
57
+ "PermissionDeniedError",
58
+ "NotFoundError",
59
+ "ConflictError",
60
+ "ValidationError",
61
+ "RateLimitError",
62
+ "InternalServerError",
63
+ "APIConnectionError",
64
+ "APITimeoutError",
65
+ "WaitTimeoutError",
66
+ "OperationFailedError",
67
+ "OrderFailedError",
68
+ "WebhookVerificationError",
69
+ "FieldError",
70
+ "__version__",
71
+ ]
@@ -0,0 +1,548 @@
1
+ """The HTTP core shared by ``Pantech`` and ``AsyncPantech``.
2
+
3
+ Everything that decides *what* is sent and *whether* to retry lives in
4
+ ``BaseClient`` and is pure; ``SyncAPIClient`` and ``AsyncAPIClient`` only add
5
+ the I/O loop around it, so the two clients cannot drift apart.
6
+
7
+ The rules, from the API's conventions:
8
+
9
+ * ``Authorization: Bearer <key>`` on every request. The key is never logged
10
+ and never shown in ``repr``.
11
+ * Every POST, PUT, PATCH and DELETE carries an ``Idempotency-Key`` (a UUIDv4
12
+ unless the caller passes one). Retries resend the same key, so the API
13
+ returns the first attempt's result instead of repeating the change.
14
+ * Retries (default 3) on connection errors, timeouts, 429, 500, 502, 503 and
15
+ 504, with jittered exponential backoff, or the server's ``Retry-After``.
16
+ * Writes the API does not replay (``_operations.NO_REPLAY``) are sent once;
17
+ only a 429 is retried, because the API refuses it before doing anything.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import json
24
+ import logging
25
+ import os
26
+ import random
27
+ import time
28
+ import uuid
29
+ from typing import (
30
+ Any,
31
+ AsyncIterator,
32
+ Callable,
33
+ Dict,
34
+ Iterator,
35
+ List,
36
+ Mapping,
37
+ Optional,
38
+ Union,
39
+ )
40
+
41
+ import httpx
42
+
43
+ from ._exceptions import (
44
+ APIConnectionError,
45
+ APITimeoutError,
46
+ PantechError,
47
+ make_api_error,
48
+ parse_retry_after,
49
+ )
50
+ from ._streaming import ServerSentEvent, SSEDecoder
51
+ from ._types import NOT_GIVEN, NotGiven, Timeout
52
+ from ._version import __version__
53
+
54
+ __all__ = ["BaseClient", "SyncAPIClient", "AsyncAPIClient", "DEFAULT_BASE_URL"]
55
+
56
+ log = logging.getLogger("pantech")
57
+
58
+ DEFAULT_BASE_URL = "https://api.pantechdynamics.com/public/v1"
59
+ # Single requests of 20 to 48 seconds have been seen on busy days, so the read
60
+ # timeout is generous. It bounds each attempt, not the whole call.
61
+ DEFAULT_TIMEOUT = httpx.Timeout(60.0, connect=10.0)
62
+ DEFAULT_MAX_RETRIES = 3
63
+ INITIAL_RETRY_DELAY = 0.5
64
+ MAX_RETRY_DELAY = 8.0
65
+ MAX_RETRY_AFTER = 60.0
66
+
67
+ RETRYABLE_STATUSES = frozenset({429, 500, 502, 503, 504})
68
+ WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
69
+
70
+ ParseAs = str # "json" (default), "bytes"
71
+
72
+
73
+ def _strip_not_given(values: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
74
+ if not values:
75
+ return {}
76
+ return {k: v for k, v in values.items() if not isinstance(v, NotGiven)}
77
+
78
+
79
+ def _query_value(value: Any) -> Any:
80
+ if isinstance(value, bool):
81
+ return "true" if value else "false"
82
+ return value
83
+
84
+
85
+ class BaseClient:
86
+ """Configuration and the pure parts of every request."""
87
+
88
+ api_key: str
89
+ base_url: str
90
+ max_retries: int
91
+ timeout: Timeout
92
+
93
+ def __init__(
94
+ self,
95
+ *,
96
+ api_key: Optional[str],
97
+ base_url: Optional[str],
98
+ timeout: Union[Timeout, NotGiven],
99
+ max_retries: int,
100
+ default_headers: Optional[Mapping[str, str]],
101
+ user_agent: Optional[str],
102
+ ) -> None:
103
+ key = api_key if api_key is not None else os.environ.get("PANTECH_API_KEY")
104
+ if not key:
105
+ raise PantechError(
106
+ "No API key. Pass api_key=... or set PANTECH_API_KEY. "
107
+ "Create a key in the console under Organization › API keys."
108
+ )
109
+ self.api_key = key
110
+ url = base_url or os.environ.get("PANTECH_API_URL") or DEFAULT_BASE_URL
111
+ self.base_url = url.rstrip("/")
112
+ self.timeout = DEFAULT_TIMEOUT if isinstance(timeout, NotGiven) else timeout
113
+ if max_retries < 0:
114
+ raise ValueError("max_retries must be 0 or more")
115
+ self.max_retries = max_retries
116
+ self._default_headers = dict(default_headers or {})
117
+ self._user_agent = user_agent or f"pantech-python/{__version__}"
118
+
119
+ def __repr__(self) -> str:
120
+ return f"{type(self).__name__}(base_url={self.base_url!r})"
121
+
122
+ # -- building requests ---------------------------------------------------
123
+
124
+ def _build_request(
125
+ self,
126
+ http: Union[httpx.Client, httpx.AsyncClient],
127
+ method: str,
128
+ path: str,
129
+ *,
130
+ query: Optional[Mapping[str, Any]],
131
+ body: Any,
132
+ idempotency_key: Optional[str],
133
+ extra_headers: Optional[Mapping[str, str]],
134
+ timeout: Union[Timeout, NotGiven],
135
+ accept: str = "application/json",
136
+ ) -> httpx.Request:
137
+ headers: Dict[str, str] = {
138
+ "Accept": accept,
139
+ "User-Agent": self._user_agent,
140
+ }
141
+ headers.update(self._default_headers)
142
+ headers["Authorization"] = f"Bearer {self.api_key}"
143
+ if idempotency_key is not None:
144
+ headers["Idempotency-Key"] = idempotency_key
145
+ content: Optional[bytes] = None
146
+ if body is not None and not isinstance(body, NotGiven):
147
+ headers["Content-Type"] = "application/json"
148
+ content = json.dumps(body, separators=(",", ":")).encode("utf-8")
149
+ if extra_headers:
150
+ headers.update(extra_headers)
151
+ params = {k: _query_value(v) for k, v in _strip_not_given(query).items() if v is not None}
152
+ effective_timeout = self.timeout if isinstance(timeout, NotGiven) else timeout
153
+ return http.build_request(
154
+ method,
155
+ self.base_url + path,
156
+ params=params or None,
157
+ headers=headers,
158
+ content=content,
159
+ timeout=effective_timeout,
160
+ )
161
+
162
+ @staticmethod
163
+ def _idempotency_key(method: str, given: Optional[str]) -> Optional[str]:
164
+ if given is not None:
165
+ return given
166
+ return str(uuid.uuid4()) if method.upper() in WRITE_METHODS else None
167
+
168
+ # -- retry decisions -----------------------------------------------------
169
+
170
+ def _should_retry_status(self, status: int, no_replay: bool, retries_taken: int, max_retries: int) -> bool:
171
+ if retries_taken >= max_retries or status not in RETRYABLE_STATUSES:
172
+ return False
173
+ return status == 429 or not no_replay
174
+
175
+ def _should_retry_error(self, no_replay: bool, retries_taken: int, max_retries: int) -> bool:
176
+ return retries_taken < max_retries and not no_replay
177
+
178
+ @staticmethod
179
+ def _retry_delay(retries_taken: int, response: Optional[httpx.Response]) -> float:
180
+ if response is not None:
181
+ retry_after = parse_retry_after(response.headers.get("retry-after"))
182
+ if retry_after is not None:
183
+ return min(retry_after, MAX_RETRY_AFTER)
184
+ delay = min(INITIAL_RETRY_DELAY * (1 << min(retries_taken, 16)), MAX_RETRY_DELAY)
185
+ # Half jitter: somewhere in [delay/2, delay], so throttled clients spread out.
186
+ return delay / 2 + random.random() * delay / 2 # noqa: S311 - jitter, not a secret
187
+
188
+ # -- responses -----------------------------------------------------------
189
+
190
+ @staticmethod
191
+ def _parse(response: httpx.Response, parse_as: ParseAs) -> Any:
192
+ if parse_as == "bytes":
193
+ return response.content
194
+ if response.status_code == 204 or not response.content:
195
+ return None
196
+ return response.json()
197
+
198
+ @staticmethod
199
+ def _log_attempt(method: str, path: str, status: Union[int, str], attempt: int) -> None:
200
+ # Never the headers or the body: they carry the key and customer data.
201
+ log.debug("%s %s -> %s (attempt %d)", method, path, status, attempt)
202
+
203
+
204
+ def _connection_error(exc: Exception) -> APIConnectionError:
205
+ if isinstance(exc, httpx.TimeoutException):
206
+ return APITimeoutError()
207
+ return APIConnectionError(f"Could not connect to the Pantech API: {type(exc).__name__}.")
208
+
209
+
210
+ class SyncAPIClient(BaseClient):
211
+ """The blocking HTTP loop."""
212
+
213
+ _http: httpx.Client
214
+
215
+ def __init__(
216
+ self,
217
+ *,
218
+ api_key: Optional[str],
219
+ base_url: Optional[str],
220
+ timeout: Union[Timeout, NotGiven],
221
+ max_retries: int,
222
+ default_headers: Optional[Mapping[str, str]],
223
+ user_agent: Optional[str],
224
+ http_client: Optional[httpx.Client],
225
+ ) -> None:
226
+ super().__init__(
227
+ api_key=api_key,
228
+ base_url=base_url,
229
+ timeout=timeout,
230
+ max_retries=max_retries,
231
+ default_headers=default_headers,
232
+ user_agent=user_agent,
233
+ )
234
+ self._owns_http = http_client is None
235
+ self._http = http_client or httpx.Client(follow_redirects=False)
236
+ self._sleep: Callable[[float], None] = time.sleep
237
+ self._clock: Callable[[], float] = time.monotonic
238
+
239
+ def close(self) -> None:
240
+ """Closes the connection pool (unless you passed your own ``http_client``)."""
241
+ if self._owns_http:
242
+ self._http.close()
243
+
244
+ def request(
245
+ self,
246
+ method: str,
247
+ path: str,
248
+ *,
249
+ query: Optional[Mapping[str, Any]] = None,
250
+ body: Any = None,
251
+ idempotency_key: Optional[str] = None,
252
+ no_replay: bool = False,
253
+ extra_headers: Optional[Mapping[str, str]] = None,
254
+ timeout: Union[Timeout, NotGiven] = NOT_GIVEN,
255
+ max_retries: Optional[int] = None,
256
+ parse_as: ParseAs = "json",
257
+ ) -> Any:
258
+ """Sends one API call with retries and returns the parsed body.
259
+
260
+ *path* is relative to the base URL (``"/instances"``). This is the
261
+ escape hatch for anything the typed methods do not cover yet.
262
+ """
263
+ response = self._send(
264
+ method,
265
+ path,
266
+ query=query,
267
+ body=body,
268
+ idempotency_key=idempotency_key,
269
+ no_replay=no_replay,
270
+ extra_headers=extra_headers,
271
+ timeout=timeout,
272
+ max_retries=max_retries,
273
+ accept="application/pdf" if parse_as == "bytes" else "application/json",
274
+ )
275
+ return self._parse(response, parse_as)
276
+
277
+ def _send(
278
+ self,
279
+ method: str,
280
+ path: str,
281
+ *,
282
+ query: Optional[Mapping[str, Any]],
283
+ body: Any,
284
+ idempotency_key: Optional[str],
285
+ no_replay: bool,
286
+ extra_headers: Optional[Mapping[str, str]],
287
+ timeout: Union[Timeout, NotGiven],
288
+ max_retries: Optional[int],
289
+ accept: str,
290
+ stream: bool = False,
291
+ ) -> httpx.Response:
292
+ method = method.upper()
293
+ key = self._idempotency_key(method, idempotency_key)
294
+ limit = self.max_retries if max_retries is None else max_retries
295
+ retries_taken = 0
296
+ while True:
297
+ request = self._build_request(
298
+ self._http,
299
+ method,
300
+ path,
301
+ query=query,
302
+ body=body,
303
+ idempotency_key=key,
304
+ extra_headers=extra_headers,
305
+ timeout=timeout,
306
+ accept=accept,
307
+ )
308
+ try:
309
+ response = self._http.send(request, stream=stream)
310
+ except httpx.TransportError as exc:
311
+ self._log_attempt(method, path, type(exc).__name__, retries_taken + 1)
312
+ if self._should_retry_error(no_replay, retries_taken, limit):
313
+ self._sleep(self._retry_delay(retries_taken, None))
314
+ retries_taken += 1
315
+ continue
316
+ raise _connection_error(exc) from None
317
+ self._log_attempt(method, path, response.status_code, retries_taken + 1)
318
+ if response.is_success:
319
+ return response
320
+ if self._should_retry_status(response.status_code, no_replay, retries_taken, limit):
321
+ response.close()
322
+ self._sleep(self._retry_delay(retries_taken, response))
323
+ retries_taken += 1
324
+ continue
325
+ if stream:
326
+ response.read()
327
+ response.close()
328
+ raise make_api_error(response)
329
+
330
+ def _paginate(
331
+ self,
332
+ path: str,
333
+ *,
334
+ query: Optional[Mapping[str, Any]] = None,
335
+ extra_headers: Optional[Mapping[str, str]] = None,
336
+ timeout: Union[Timeout, NotGiven] = NOT_GIVEN,
337
+ ) -> Iterator[Any]:
338
+ params = _strip_not_given(query)
339
+ seen: List[str] = []
340
+ while True:
341
+ page = self.request("GET", path, query=params, extra_headers=extra_headers, timeout=timeout)
342
+ yield from page.get("data") or []
343
+ cursor = page.get("next_cursor")
344
+ if not cursor or cursor in seen:
345
+ return
346
+ seen.append(cursor)
347
+ params = {**params, "cursor": cursor}
348
+
349
+ def _stream_events(
350
+ self,
351
+ path: str,
352
+ *,
353
+ extra_headers: Optional[Mapping[str, str]] = None,
354
+ timeout: Union[Timeout, NotGiven] = NOT_GIVEN,
355
+ ) -> Iterator[ServerSentEvent]:
356
+ response = self._send(
357
+ "GET",
358
+ path,
359
+ query=None,
360
+ body=None,
361
+ idempotency_key=None,
362
+ no_replay=False,
363
+ extra_headers=extra_headers,
364
+ timeout=timeout,
365
+ max_retries=None,
366
+ accept="text/event-stream",
367
+ stream=True,
368
+ )
369
+ decoder = SSEDecoder()
370
+ try:
371
+ for line in response.iter_lines():
372
+ event = decoder.feed(line)
373
+ if event is not None:
374
+ yield event
375
+ event = decoder.feed("")
376
+ if event is not None:
377
+ yield event
378
+ finally:
379
+ response.close()
380
+
381
+
382
+ class AsyncAPIClient(BaseClient):
383
+ """The asyncio HTTP loop. Same rules as ``SyncAPIClient``."""
384
+
385
+ _http: httpx.AsyncClient
386
+
387
+ def __init__(
388
+ self,
389
+ *,
390
+ api_key: Optional[str],
391
+ base_url: Optional[str],
392
+ timeout: Union[Timeout, NotGiven],
393
+ max_retries: int,
394
+ default_headers: Optional[Mapping[str, str]],
395
+ user_agent: Optional[str],
396
+ http_client: Optional[httpx.AsyncClient],
397
+ ) -> None:
398
+ super().__init__(
399
+ api_key=api_key,
400
+ base_url=base_url,
401
+ timeout=timeout,
402
+ max_retries=max_retries,
403
+ default_headers=default_headers,
404
+ user_agent=user_agent,
405
+ )
406
+ self._owns_http = http_client is None
407
+ self._http = http_client or httpx.AsyncClient(follow_redirects=False)
408
+ self._sleep: Callable[[float], Any] = asyncio.sleep
409
+ self._clock: Callable[[], float] = time.monotonic
410
+
411
+ async def close(self) -> None:
412
+ """Closes the connection pool (unless you passed your own ``http_client``)."""
413
+ if self._owns_http:
414
+ await self._http.aclose()
415
+
416
+ async def request(
417
+ self,
418
+ method: str,
419
+ path: str,
420
+ *,
421
+ query: Optional[Mapping[str, Any]] = None,
422
+ body: Any = None,
423
+ idempotency_key: Optional[str] = None,
424
+ no_replay: bool = False,
425
+ extra_headers: Optional[Mapping[str, str]] = None,
426
+ timeout: Union[Timeout, NotGiven] = NOT_GIVEN,
427
+ max_retries: Optional[int] = None,
428
+ parse_as: ParseAs = "json",
429
+ ) -> Any:
430
+ """Sends one API call with retries and returns the parsed body."""
431
+ response = await self._send(
432
+ method,
433
+ path,
434
+ query=query,
435
+ body=body,
436
+ idempotency_key=idempotency_key,
437
+ no_replay=no_replay,
438
+ extra_headers=extra_headers,
439
+ timeout=timeout,
440
+ max_retries=max_retries,
441
+ accept="application/pdf" if parse_as == "bytes" else "application/json",
442
+ )
443
+ return self._parse(response, parse_as)
444
+
445
+ async def _send(
446
+ self,
447
+ method: str,
448
+ path: str,
449
+ *,
450
+ query: Optional[Mapping[str, Any]],
451
+ body: Any,
452
+ idempotency_key: Optional[str],
453
+ no_replay: bool,
454
+ extra_headers: Optional[Mapping[str, str]],
455
+ timeout: Union[Timeout, NotGiven],
456
+ max_retries: Optional[int],
457
+ accept: str,
458
+ stream: bool = False,
459
+ ) -> httpx.Response:
460
+ method = method.upper()
461
+ key = self._idempotency_key(method, idempotency_key)
462
+ limit = self.max_retries if max_retries is None else max_retries
463
+ retries_taken = 0
464
+ while True:
465
+ request = self._build_request(
466
+ self._http,
467
+ method,
468
+ path,
469
+ query=query,
470
+ body=body,
471
+ idempotency_key=key,
472
+ extra_headers=extra_headers,
473
+ timeout=timeout,
474
+ accept=accept,
475
+ )
476
+ try:
477
+ response = await self._http.send(request, stream=stream)
478
+ except httpx.TransportError as exc:
479
+ self._log_attempt(method, path, type(exc).__name__, retries_taken + 1)
480
+ if self._should_retry_error(no_replay, retries_taken, limit):
481
+ await self._sleep(self._retry_delay(retries_taken, None))
482
+ retries_taken += 1
483
+ continue
484
+ raise _connection_error(exc) from None
485
+ self._log_attempt(method, path, response.status_code, retries_taken + 1)
486
+ if response.is_success:
487
+ return response
488
+ if self._should_retry_status(response.status_code, no_replay, retries_taken, limit):
489
+ await response.aclose()
490
+ await self._sleep(self._retry_delay(retries_taken, response))
491
+ retries_taken += 1
492
+ continue
493
+ if stream:
494
+ await response.aread()
495
+ await response.aclose()
496
+ raise make_api_error(response)
497
+
498
+ async def _paginate(
499
+ self,
500
+ path: str,
501
+ *,
502
+ query: Optional[Mapping[str, Any]] = None,
503
+ extra_headers: Optional[Mapping[str, str]] = None,
504
+ timeout: Union[Timeout, NotGiven] = NOT_GIVEN,
505
+ ) -> AsyncIterator[Any]:
506
+ params = _strip_not_given(query)
507
+ seen: List[str] = []
508
+ while True:
509
+ page = await self.request("GET", path, query=params, extra_headers=extra_headers, timeout=timeout)
510
+ for item in page.get("data") or []:
511
+ yield item
512
+ cursor = page.get("next_cursor")
513
+ if not cursor or cursor in seen:
514
+ return
515
+ seen.append(cursor)
516
+ params = {**params, "cursor": cursor}
517
+
518
+ async def _stream_events(
519
+ self,
520
+ path: str,
521
+ *,
522
+ extra_headers: Optional[Mapping[str, str]] = None,
523
+ timeout: Union[Timeout, NotGiven] = NOT_GIVEN,
524
+ ) -> AsyncIterator[ServerSentEvent]:
525
+ response = await self._send(
526
+ "GET",
527
+ path,
528
+ query=None,
529
+ body=None,
530
+ idempotency_key=None,
531
+ no_replay=False,
532
+ extra_headers=extra_headers,
533
+ timeout=timeout,
534
+ max_retries=None,
535
+ accept="text/event-stream",
536
+ stream=True,
537
+ )
538
+ decoder = SSEDecoder()
539
+ try:
540
+ async for line in response.aiter_lines():
541
+ event = decoder.feed(line)
542
+ if event is not None:
543
+ yield event
544
+ event = decoder.feed("")
545
+ if event is not None:
546
+ yield event
547
+ finally:
548
+ await response.aclose()