basaltic-sdk-python 0.1.1__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.
Files changed (47) hide show
  1. basaltic/__init__.py +26 -0
  2. basaltic/_client.py +136 -0
  3. basaltic/_common.py +284 -0
  4. basaltic/_transport.py +196 -0
  5. basaltic/_version.py +3 -0
  6. basaltic/auth.py +231 -0
  7. basaltic/client.py +144 -0
  8. basaltic/config.py +151 -0
  9. basaltic/errors.py +73 -0
  10. basaltic/models/__init__.py +17 -0
  11. basaltic/models/audit.py +85 -0
  12. basaltic/models/billing.py +284 -0
  13. basaltic/models/catalog.py +35 -0
  14. basaltic/models/certificate.py +127 -0
  15. basaltic/models/compute.py +905 -0
  16. basaltic/models/dns.py +256 -0
  17. basaltic/models/iam.py +736 -0
  18. basaltic/models/kms.py +157 -0
  19. basaltic/models/loadbalancer.py +574 -0
  20. basaltic/models/network.py +985 -0
  21. basaltic/models/quota.py +30 -0
  22. basaltic/models/secrets.py +136 -0
  23. basaltic/models/storage.py +789 -0
  24. basaltic/models/telemetry.py +393 -0
  25. basaltic/models/workspace.py +734 -0
  26. basaltic/py.typed +0 -0
  27. basaltic/response.py +152 -0
  28. basaltic/services/__init__.py +1 -0
  29. basaltic/services/audit.py +211 -0
  30. basaltic/services/billing.py +777 -0
  31. basaltic/services/catalog.py +172 -0
  32. basaltic/services/certificate.py +395 -0
  33. basaltic/services/compute.py +2395 -0
  34. basaltic/services/dns.py +1079 -0
  35. basaltic/services/iam.py +3405 -0
  36. basaltic/services/kms.py +742 -0
  37. basaltic/services/loadbalancer.py +1727 -0
  38. basaltic/services/network.py +4691 -0
  39. basaltic/services/quota.py +75 -0
  40. basaltic/services/secrets.py +550 -0
  41. basaltic/services/storage.py +3059 -0
  42. basaltic/services/telemetry.py +1316 -0
  43. basaltic/services/workspace.py +3899 -0
  44. basaltic_sdk_python-0.1.1.dist-info/METADATA +211 -0
  45. basaltic_sdk_python-0.1.1.dist-info/RECORD +47 -0
  46. basaltic_sdk_python-0.1.1.dist-info/WHEEL +4 -0
  47. basaltic_sdk_python-0.1.1.dist-info/licenses/LICENSE +202 -0
basaltic/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """Official Basaltic Python SDK."""
2
+
3
+ from ._common import AsyncBinaryBody as AsyncBinaryBody
4
+ from ._common import BinaryBody as BinaryBody
5
+ from ._common import WebSocketConnection as WebSocketConnection
6
+ from ._version import __version__ as __version__
7
+ from .auth import AsyncClientCredentials as AsyncClientCredentials
8
+ from .auth import AsyncStaticToken as AsyncStaticToken
9
+ from .auth import AsyncTokenProvider as AsyncTokenProvider
10
+ from .auth import ClientCredentials as ClientCredentials
11
+ from .auth import StaticToken as StaticToken
12
+ from .auth import TokenProvider as TokenProvider
13
+ from .client import AsyncClient as AsyncClient
14
+ from .client import Client as Client
15
+ from .config import Config as Config
16
+ from .config import RequestOptions as RequestOptions
17
+ from .config import new_idempotency_key as new_idempotency_key
18
+ from .errors import AmbiguousReferenceError as AmbiguousReferenceError
19
+ from .errors import ApiError as ApiError
20
+ from .errors import AuthenticationError as AuthenticationError
21
+ from .errors import BasalticError as BasalticError
22
+ from .errors import ProtocolError as ProtocolError
23
+ from .errors import RequestTimeoutError as RequestTimeoutError
24
+ from .errors import TransportError as TransportError
25
+ from .response import ApiResponse as ApiResponse
26
+ from .response import Page as Page
basaltic/_client.py ADDED
@@ -0,0 +1,136 @@
1
+ """HTTP client lifetime and shared credential configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from types import TracebackType
7
+ from typing import Self, TypedDict, Unpack, cast
8
+
9
+ import httpx
10
+
11
+ from ._transport import AsyncTransport, SyncTransport
12
+ from .auth import (
13
+ AsyncClientCredentials,
14
+ AsyncStaticToken,
15
+ AsyncTokenProvider,
16
+ ClientCredentials,
17
+ StaticToken,
18
+ TokenProvider,
19
+ )
20
+ from .config import Config
21
+
22
+
23
+ class ClientOptions(TypedDict, total=False):
24
+ access_key_id: str
25
+ secret_access_key: str
26
+ access_token: str
27
+ anonymous: bool
28
+ region: str
29
+ account_id: str
30
+ domain: str
31
+ endpoints: Mapping[str, str]
32
+ token_url: str
33
+ read_environment: bool
34
+ timeout: float
35
+ max_attempts: int
36
+ base_delay: float
37
+ max_delay: float
38
+
39
+
40
+ class SyncClientBase:
41
+ def __init__(
42
+ self,
43
+ config: Config | None = None,
44
+ *,
45
+ http_client: httpx.Client | None = None,
46
+ token_provider: TokenProvider | None = None,
47
+ **options: Unpack[ClientOptions],
48
+ ) -> None:
49
+ if config is not None and (options or token_provider is not None):
50
+ raise ValueError("Pass either Config or configuration keywords.")
51
+ self.config = config or Config(token_provider=token_provider, **options)
52
+ self._owns_http = http_client is None
53
+ self._http = http_client if http_client is not None else httpx.Client(trust_env=False)
54
+ provider = cast(TokenProvider | None, self.config.token_provider)
55
+ if self.config.anonymous:
56
+ provider = None
57
+ elif provider is None:
58
+ if self.config.access_token:
59
+ provider = StaticToken(self.config.access_token)
60
+ else:
61
+ provider = ClientCredentials(
62
+ access_key_id=self.config.access_key_id or "",
63
+ secret_access_key=self.config.secret_access_key or "",
64
+ token_url=self.config.token_url
65
+ or self.config.endpoint("iam", "https://iam.basaltic.sh") + "/v1/oauth/token",
66
+ http_client=self._http,
67
+ timeout=self.config.timeout,
68
+ )
69
+ self._transport = SyncTransport(self.config, self._http, provider)
70
+
71
+ def close(self) -> None:
72
+ if self._owns_http:
73
+ self._http.close()
74
+
75
+ def __enter__(self) -> Self:
76
+ return self
77
+
78
+ def __exit__(
79
+ self,
80
+ exc_type: type[BaseException] | None,
81
+ exc: BaseException | None,
82
+ traceback: TracebackType | None,
83
+ ) -> None:
84
+ self.close()
85
+
86
+
87
+ class AsyncClientBase:
88
+ def __init__(
89
+ self,
90
+ config: Config | None = None,
91
+ *,
92
+ http_client: httpx.AsyncClient | None = None,
93
+ token_provider: AsyncTokenProvider | None = None,
94
+ **options: Unpack[ClientOptions],
95
+ ) -> None:
96
+ if config is not None and (options or token_provider is not None):
97
+ raise ValueError("Pass either Config or configuration keywords.")
98
+ self.config = config or Config(token_provider=token_provider, **options)
99
+ self._owns_http = http_client is None
100
+ self._http = http_client if http_client is not None else httpx.AsyncClient(trust_env=False)
101
+ provider = cast(AsyncTokenProvider | None, self.config.token_provider)
102
+ self._owns_provider = False
103
+ if self.config.anonymous:
104
+ provider = None
105
+ elif provider is None:
106
+ if self.config.access_token:
107
+ provider = AsyncStaticToken(self.config.access_token)
108
+ else:
109
+ provider = AsyncClientCredentials(
110
+ access_key_id=self.config.access_key_id or "",
111
+ secret_access_key=self.config.secret_access_key or "",
112
+ token_url=self.config.token_url
113
+ or self.config.endpoint("iam", "https://iam.basaltic.sh") + "/v1/oauth/token",
114
+ http_client=self._http,
115
+ timeout=self.config.timeout,
116
+ )
117
+ self._owns_provider = True
118
+ self._transport = AsyncTransport(self.config, self._http, provider)
119
+
120
+ async def aclose(self) -> None:
121
+ provider = self._transport.provider
122
+ if self._owns_provider and isinstance(provider, AsyncClientCredentials):
123
+ await provider.aclose()
124
+ if self._owns_http:
125
+ await self._http.aclose()
126
+
127
+ async def __aenter__(self) -> Self:
128
+ return self
129
+
130
+ async def __aexit__(
131
+ self,
132
+ exc_type: type[BaseException] | None,
133
+ exc: BaseException | None,
134
+ traceback: TracebackType | None,
135
+ ) -> None:
136
+ await self.aclose()
basaltic/_common.py ADDED
@@ -0,0 +1,284 @@
1
+ """Shared wire preparation and response decisions for both HTTP drivers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import re
8
+ import time
9
+ from collections.abc import AsyncIterable, Iterable, Mapping
10
+ from dataclasses import dataclass
11
+ from datetime import timezone
12
+ from email.utils import parsedate_to_datetime
13
+ from typing import Any, TypeAlias
14
+ from urllib.parse import quote, urlsplit, urlunsplit
15
+
16
+ import httpx
17
+
18
+ from ._version import __version__
19
+ from .config import Config, RequestOptions, attempt_count, positive_timeout
20
+ from .errors import ApiError, ProtocolError
21
+ from .response import ApiResponse, Page
22
+
23
+ BinaryBody: TypeAlias = bytes | bytearray | memoryview | Iterable[bytes]
24
+ AsyncBinaryBody: TypeAlias = bytes | bytearray | memoryview | AsyncIterable[bytes]
25
+
26
+
27
+ class Unset:
28
+ """Distinguishes an omitted optional body from explicit JSON null."""
29
+
30
+
31
+ UNSET = Unset()
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class WebSocketConnection:
36
+ url: str
37
+ headers: Mapping[str, str]
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Operation:
42
+ id: str
43
+ method: str
44
+ path: str
45
+ authenticated: bool
46
+ requiredQuery: tuple[str, ...] | list[str]
47
+ requiredHeaders: tuple[str, ...] | list[str]
48
+ queryEncoding: Mapping[str, Mapping[str, object]]
49
+ bodyRequired: bool
50
+ contentType: str
51
+ accept: str
52
+ itemsKey: str = ""
53
+
54
+
55
+ def encode_query(
56
+ values: Mapping[str, object], encodings: Mapping[str, Mapping[str, object]] | None = None
57
+ ) -> str:
58
+ def scalar(value: object) -> str:
59
+ if isinstance(value, bool):
60
+ return "true" if value else "false"
61
+ if isinstance(value, float) and not math.isfinite(value):
62
+ raise ValueError("Query values must be finite.")
63
+ if not isinstance(value, (str, int, float)):
64
+ raise TypeError("Query values must be scalars or lists of scalars.")
65
+ return str(value)
66
+
67
+ pairs: list[str] = []
68
+ for key, value in values.items():
69
+ if value is None or isinstance(value, Unset):
70
+ continue
71
+ prefix = quote(key, safe="") + "="
72
+ if isinstance(value, (list, tuple)):
73
+ items = [scalar(item) for item in value]
74
+ encoding = (encodings or {}).get(key, {})
75
+ if encoding.get("explode", True):
76
+ pairs.extend(prefix + quote(item, safe="") for item in items)
77
+ else:
78
+ separator = {"spaceDelimited": " ", "pipeDelimited": "|"}.get(
79
+ str(encoding.get("style")), ","
80
+ )
81
+ pairs.append(prefix + quote(separator.join(items), safe=""))
82
+ else:
83
+ pairs.append(prefix + quote(scalar(value), safe=""))
84
+ return "&".join(pairs)
85
+
86
+
87
+ @dataclass
88
+ class Prepared:
89
+ operation: Operation
90
+ url: str
91
+ headers: dict[str, str]
92
+ body: Any
93
+ streaming: bool
94
+ max_attempts: int
95
+ timeout: float
96
+ repeatable: bool
97
+
98
+ def request(self, token: str) -> httpx.Request:
99
+ headers = dict(self.headers)
100
+ if token:
101
+ headers["Authorization"] = "Bearer " + token
102
+ return httpx.Request(
103
+ self.operation.method,
104
+ self.url,
105
+ headers=headers,
106
+ content=self.body,
107
+ extensions={"timeout": httpx.Timeout(self.timeout).as_dict()},
108
+ )
109
+
110
+ def websocket(self, token: str) -> WebSocketConnection:
111
+ headers = dict(self.headers)
112
+ if token:
113
+ headers["Authorization"] = "Bearer " + token
114
+ u = urlsplit(self.url)
115
+ return WebSocketConnection(
116
+ urlunsplit(("wss" if u.scheme == "https" else "ws", u.netloc, u.path, u.query, "")),
117
+ headers,
118
+ )
119
+
120
+
121
+ _RESERVED = {
122
+ "authorization",
123
+ "host",
124
+ "cookie",
125
+ "content-length",
126
+ "content-type",
127
+ "accept",
128
+ "user-agent",
129
+ "x-account-id",
130
+ "idempotency-key",
131
+ }
132
+
133
+
134
+ def prepare(
135
+ config: Config,
136
+ service: str,
137
+ endpoint: str,
138
+ operation: Operation,
139
+ path: Mapping[str, str],
140
+ body: object,
141
+ query: Mapping[str, object] | None,
142
+ options: RequestOptions | None,
143
+ *,
144
+ asynchronous: bool = False,
145
+ ) -> Prepared:
146
+ opts = options or RequestOptions()
147
+ values = query or {}
148
+ if operation.bodyRequired and (isinstance(body, Unset) or body is None):
149
+ raise ValueError(f"{operation.id}: request body is required.")
150
+ for key in operation.requiredQuery:
151
+ if values.get(key) is None:
152
+ raise ValueError(f"{operation.id}: missing required query parameter {key}.")
153
+
154
+ def replace(match: re.Match[str]) -> str:
155
+ value = path.get(match[1], "")
156
+ if not isinstance(value, str) or value in ("", ".", ".."):
157
+ raise ValueError(f"{operation.id}: invalid path parameter {match[1]}.")
158
+ return quote(value, safe="")
159
+
160
+ route = re.sub(r"\{([^}]+)\}", replace, operation.path)
161
+ qs = encode_query(values, operation.queryEncoding)
162
+ url = config.endpoint(service, endpoint) + route + ("?" + qs if qs else "")
163
+ headers = httpx.Headers(
164
+ {"Accept": operation.accept, "User-Agent": f"basaltic-python/{__version__}"}
165
+ )
166
+ account = opts.account_id if opts.account_id is not None else config.account_id
167
+ if account:
168
+ headers["X-Account-Id"] = account
169
+ if opts.idempotency_key is not None:
170
+ if not opts.idempotency_key.strip():
171
+ raise ValueError("idempotency_key must not be empty.")
172
+ headers["Idempotency-Key"] = opts.idempotency_key
173
+ for key, value in opts.headers.items():
174
+ if key.lower() in _RESERVED:
175
+ raise ValueError(f"Cannot override managed header {key}.")
176
+ if re.search(r"[\r\n\x00]", key + value):
177
+ raise ValueError("Invalid HTTP header.")
178
+ headers[key] = value
179
+ for key, value in headers.items():
180
+ if re.search(r"[\x00-\x1f\x7f]", key + value):
181
+ raise ValueError("Invalid HTTP header.")
182
+ for key in operation.requiredHeaders:
183
+ if not headers.get(key):
184
+ raise ValueError(f"{operation.id}: missing required header {key}.")
185
+ content: object = None
186
+ streaming = False
187
+ if not isinstance(body, Unset):
188
+ if operation.contentType == "application/json":
189
+ content = json.dumps(
190
+ body, ensure_ascii=False, separators=(",", ":"), allow_nan=False
191
+ ).encode()
192
+ elif operation.contentType == "application/x-www-form-urlencoded":
193
+ if not isinstance(body, Mapping):
194
+ raise TypeError("Form body must be a mapping.")
195
+ content = encode_query(body).encode()
196
+ elif isinstance(body, (bytes, bytearray, memoryview)):
197
+ content = bytes(body)
198
+ elif isinstance(body, str):
199
+ content = body.encode()
200
+ elif not isinstance(body, Mapping) and isinstance(
201
+ body, AsyncIterable if asynchronous else Iterable
202
+ ):
203
+ content, streaming = body, True
204
+ else:
205
+ raise TypeError("Binary body must be bytes or a matching sync/async byte iterable.")
206
+ if operation.contentType:
207
+ headers["Content-Type"] = operation.contentType
208
+ limit = attempt_count(
209
+ opts.max_attempts if opts.max_attempts is not None else config.max_attempts
210
+ )
211
+ return Prepared(
212
+ operation,
213
+ url,
214
+ dict(headers),
215
+ content,
216
+ streaming,
217
+ 1 if streaming else limit,
218
+ positive_timeout(opts.timeout if opts.timeout is not None else config.timeout),
219
+ operation.method in ("GET", "HEAD", "OPTIONS", "PUT", "DELETE")
220
+ or opts.idempotency_key is not None,
221
+ )
222
+
223
+
224
+ def backoff(config: Config, attempt: int, random_value: float) -> float:
225
+ if not math.isfinite(random_value):
226
+ raise ValueError("Random source must return a finite number.")
227
+ return min(1, max(0, random_value)) * min(
228
+ config.max_delay, float(config.base_delay * 2 ** (attempt - 1))
229
+ )
230
+
231
+
232
+ def retry_delay(
233
+ config: Config, attempt: int, value: str | None, random_value: float, now: float | None = None
234
+ ) -> float | None:
235
+ if not value:
236
+ return backoff(config, attempt, random_value)
237
+ parsed = 0.0
238
+ try:
239
+ if value.isdigit():
240
+ parsed = float(value)
241
+ else:
242
+ date = parsedate_to_datetime(value)
243
+ if date.tzinfo is None:
244
+ date = date.replace(tzinfo=timezone.utc)
245
+ parsed = date.timestamp() - (time.time() if now is None else now)
246
+ except (ValueError, OverflowError, TypeError):
247
+ return backoff(config, attempt, random_value)
248
+ return max(0, parsed) if parsed <= config.max_delay else None
249
+
250
+
251
+ def api_error(response: httpx.Response, raw: bytes, operation_id: str) -> ApiError:
252
+ data: object = None
253
+ try:
254
+ data = json.loads(raw)
255
+ except (ValueError, UnicodeDecodeError):
256
+ pass
257
+ error = data.get("error", {}) if isinstance(data, dict) else {}
258
+ if not isinstance(error, dict):
259
+ error = {}
260
+ code, message, request_id = error.get("code"), error.get("message"), error.get("request_id")
261
+ return ApiError(
262
+ response.status_code,
263
+ code if isinstance(code, str) else "",
264
+ message if isinstance(message, str) else "Unexpected API response",
265
+ request_id if isinstance(request_id, str) else response.headers.get("X-Request-Id", ""),
266
+ operation_id,
267
+ dict(response.headers),
268
+ )
269
+
270
+
271
+ def decoded(response: httpx.Response, raw: bytes, op: Operation, kind: str) -> object:
272
+ data: object = None
273
+ valid = True
274
+ try:
275
+ data = json.loads(raw) if raw.strip() else {}
276
+ except (ValueError, UnicodeDecodeError):
277
+ valid = False
278
+ if not valid or not isinstance(data, (dict, list)):
279
+ raise ProtocolError(f"{op.id}: expected a JSON object or array.")
280
+ if kind == "page":
281
+ page: Page[Any, Any] = Page(data, response, op.itemsKey)
282
+ _ = page.items
283
+ return page
284
+ return ApiResponse(data, response)
basaltic/_transport.py ADDED
@@ -0,0 +1,196 @@
1
+ """HTTP drivers. Shared preparation keeps synchronous and asyncio semantics aligned."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import random
7
+ import time
8
+ from collections.abc import Awaitable, Callable, Mapping
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from ._common import UNSET, Operation, api_error, backoff, decoded, prepare, retry_delay
14
+ from .auth import AsyncTokenProvider, TokenProvider
15
+ from .config import Config, RequestOptions, validate_token
16
+ from .errors import RequestTimeoutError, TransportError
17
+
18
+
19
+ class SyncTransport:
20
+ def __init__(self, config: Config, http: httpx.Client, provider: TokenProvider | None) -> None:
21
+ self.config, self.http, self.provider = config, http, provider
22
+ self.sleep: Callable[[float], None] = time.sleep
23
+ self.random: Callable[[], float] = random.random
24
+
25
+ def _token(self, authenticated: bool) -> str:
26
+ if not authenticated:
27
+ return ""
28
+ if self.provider is None:
29
+ raise ValueError("Anonymous access cannot call an authenticated operation.")
30
+ return validate_token(self.provider.get_token())
31
+
32
+ def request(
33
+ self,
34
+ service: str,
35
+ endpoint: str,
36
+ op: Operation,
37
+ kind: str,
38
+ path: Mapping[str, str],
39
+ body: object = UNSET,
40
+ query: Mapping[str, object] | None = None,
41
+ options: RequestOptions | None = None,
42
+ ) -> Any:
43
+ prepared = prepare(self.config, service, endpoint, op, path, body, query, options)
44
+ if kind == "websocket":
45
+ return prepared.websocket(self._token(op.authenticated))
46
+ refreshed = False
47
+ for attempt in range(1, prepared.max_attempts + 1):
48
+ response: httpx.Response | None = None
49
+ returned_stream = False
50
+ delay: float | None = None
51
+ failure: TransportError | None = None
52
+ try:
53
+ token = self._token(op.authenticated)
54
+ response = self.http.send(
55
+ prepared.request(token), auth=None, stream=True, follow_redirects=False
56
+ )
57
+ if response.is_success:
58
+ if kind == "binary":
59
+ returned_stream = True
60
+ return response
61
+ if kind == "discard":
62
+ return None
63
+ return decoded(response, response.read(), op, kind)
64
+ invalidate = getattr(self.provider, "invalidate", None)
65
+ if (
66
+ response.status_code == 401
67
+ and op.authenticated
68
+ and not refreshed
69
+ and attempt < prepared.max_attempts
70
+ and callable(invalidate)
71
+ ):
72
+ invalidate(token)
73
+ refreshed, delay = True, 0.0
74
+ elif (
75
+ prepared.repeatable
76
+ and attempt < prepared.max_attempts
77
+ and response.status_code in (429, 500, 502, 503, 504)
78
+ ):
79
+ delay = retry_delay(
80
+ self.config, attempt, response.headers.get("Retry-After"), self.random()
81
+ )
82
+ if delay is None:
83
+ raw = bytearray()
84
+ for chunk in response.iter_bytes():
85
+ raw.extend(chunk[: 1048576 - len(raw)])
86
+ if len(raw) >= 1048576:
87
+ break
88
+ raise api_error(response, bytes(raw), op.id)
89
+ except httpx.HTTPError as error:
90
+ failure = (
91
+ RequestTimeoutError(f"{op.id}: HTTP timeout.")
92
+ if isinstance(error, httpx.TimeoutException)
93
+ else TransportError(f"{op.id}: HTTP transport failed.")
94
+ )
95
+ finally:
96
+ if response is not None and not returned_stream:
97
+ response.close()
98
+ # Raise outside the HTTP exception handler so no credential-bearing
99
+ # request or exception survives through __context__ or __cause__.
100
+ if failure is not None:
101
+ if not prepared.repeatable or attempt == prepared.max_attempts:
102
+ raise failure
103
+ delay = backoff(self.config, attempt, self.random())
104
+ self.sleep(delay or 0.0)
105
+ raise TransportError("Request attempts exhausted.")
106
+
107
+
108
+ class AsyncTransport:
109
+ def __init__(
110
+ self, config: Config, http: httpx.AsyncClient, provider: AsyncTokenProvider | None
111
+ ) -> None:
112
+ self.config, self.http, self.provider = config, http, provider
113
+ self.sleep: Callable[[float], Awaitable[None]] = asyncio.sleep
114
+ self.random: Callable[[], float] = random.random
115
+
116
+ async def _token(self, authenticated: bool) -> str:
117
+ if not authenticated:
118
+ return ""
119
+ if self.provider is None:
120
+ raise ValueError("Anonymous access cannot call an authenticated operation.")
121
+ return validate_token(await self.provider.get_token())
122
+
123
+ async def request(
124
+ self,
125
+ service: str,
126
+ endpoint: str,
127
+ op: Operation,
128
+ kind: str,
129
+ path: Mapping[str, str],
130
+ body: object = UNSET,
131
+ query: Mapping[str, object] | None = None,
132
+ options: RequestOptions | None = None,
133
+ ) -> Any:
134
+ prepared = prepare(
135
+ self.config, service, endpoint, op, path, body, query, options, asynchronous=True
136
+ )
137
+ if kind == "websocket":
138
+ return prepared.websocket(await self._token(op.authenticated))
139
+ refreshed = False
140
+ for attempt in range(1, prepared.max_attempts + 1):
141
+ response: httpx.Response | None = None
142
+ returned_stream = False
143
+ delay: float | None = None
144
+ failure: TransportError | None = None
145
+ try:
146
+ token = await self._token(op.authenticated)
147
+ response = await self.http.send(
148
+ prepared.request(token), auth=None, stream=True, follow_redirects=False
149
+ )
150
+ if response.is_success:
151
+ if kind == "binary":
152
+ returned_stream = True
153
+ return response
154
+ if kind == "discard":
155
+ return None
156
+ return decoded(response, await response.aread(), op, kind)
157
+ invalidate = getattr(self.provider, "invalidate", None)
158
+ if (
159
+ response.status_code == 401
160
+ and op.authenticated
161
+ and not refreshed
162
+ and attempt < prepared.max_attempts
163
+ and callable(invalidate)
164
+ ):
165
+ invalidate(token)
166
+ refreshed, delay = True, 0.0
167
+ elif (
168
+ prepared.repeatable
169
+ and attempt < prepared.max_attempts
170
+ and response.status_code in (429, 500, 502, 503, 504)
171
+ ):
172
+ delay = retry_delay(
173
+ self.config, attempt, response.headers.get("Retry-After"), self.random()
174
+ )
175
+ if delay is None:
176
+ raw = bytearray()
177
+ async for chunk in response.aiter_bytes():
178
+ raw.extend(chunk[: 1048576 - len(raw)])
179
+ if len(raw) >= 1048576:
180
+ break
181
+ raise api_error(response, bytes(raw), op.id)
182
+ except httpx.HTTPError as error:
183
+ failure = (
184
+ RequestTimeoutError(f"{op.id}: HTTP timeout.")
185
+ if isinstance(error, httpx.TimeoutException)
186
+ else TransportError(f"{op.id}: HTTP transport failed.")
187
+ )
188
+ finally:
189
+ if response is not None and not returned_stream:
190
+ await response.aclose()
191
+ if failure is not None:
192
+ if not prepared.repeatable or attempt == prepared.max_attempts:
193
+ raise failure
194
+ delay = backoff(self.config, attempt, self.random())
195
+ await self.sleep(delay or 0.0)
196
+ raise TransportError("Request attempts exhausted.")
basaltic/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Basaltic SDK version."""
2
+
3
+ __version__ = "0.1.1"