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.
- basaltic/__init__.py +26 -0
- basaltic/_client.py +136 -0
- basaltic/_common.py +284 -0
- basaltic/_transport.py +196 -0
- basaltic/_version.py +3 -0
- basaltic/auth.py +231 -0
- basaltic/client.py +144 -0
- basaltic/config.py +151 -0
- basaltic/errors.py +73 -0
- basaltic/models/__init__.py +17 -0
- basaltic/models/audit.py +85 -0
- basaltic/models/billing.py +284 -0
- basaltic/models/catalog.py +35 -0
- basaltic/models/certificate.py +127 -0
- basaltic/models/compute.py +905 -0
- basaltic/models/dns.py +256 -0
- basaltic/models/iam.py +736 -0
- basaltic/models/kms.py +157 -0
- basaltic/models/loadbalancer.py +574 -0
- basaltic/models/network.py +985 -0
- basaltic/models/quota.py +30 -0
- basaltic/models/secrets.py +136 -0
- basaltic/models/storage.py +789 -0
- basaltic/models/telemetry.py +393 -0
- basaltic/models/workspace.py +734 -0
- basaltic/py.typed +0 -0
- basaltic/response.py +152 -0
- basaltic/services/__init__.py +1 -0
- basaltic/services/audit.py +211 -0
- basaltic/services/billing.py +777 -0
- basaltic/services/catalog.py +172 -0
- basaltic/services/certificate.py +395 -0
- basaltic/services/compute.py +2395 -0
- basaltic/services/dns.py +1079 -0
- basaltic/services/iam.py +3405 -0
- basaltic/services/kms.py +742 -0
- basaltic/services/loadbalancer.py +1727 -0
- basaltic/services/network.py +4691 -0
- basaltic/services/quota.py +75 -0
- basaltic/services/secrets.py +550 -0
- basaltic/services/storage.py +3059 -0
- basaltic/services/telemetry.py +1316 -0
- basaltic/services/workspace.py +3899 -0
- basaltic_sdk_python-0.1.1.dist-info/METADATA +211 -0
- basaltic_sdk_python-0.1.1.dist-info/RECORD +47 -0
- basaltic_sdk_python-0.1.1.dist-info/WHEEL +4 -0
- basaltic_sdk_python-0.1.1.dist-info/licenses/LICENSE +202 -0
basaltic/auth.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Cached credentials with thread-safe and asyncio single-flight refresh."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from .config import positive_timeout, validate_token, validate_url
|
|
17
|
+
from .errors import AuthenticationError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TokenProvider(Protocol):
|
|
21
|
+
def get_token(self) -> str: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AsyncTokenProvider(Protocol):
|
|
25
|
+
async def get_token(self) -> str: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StaticToken:
|
|
29
|
+
def __init__(self, token: str) -> None:
|
|
30
|
+
self.__token = validate_token(token)
|
|
31
|
+
|
|
32
|
+
def get_token(self) -> str:
|
|
33
|
+
return self.__token
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AsyncStaticToken:
|
|
37
|
+
def __init__(self, token: str) -> None:
|
|
38
|
+
self.__token = validate_token(token)
|
|
39
|
+
|
|
40
|
+
async def get_token(self) -> str:
|
|
41
|
+
return self.__token
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _token_response(status: int, raw: bytes) -> tuple[str, float]:
|
|
45
|
+
data: object = None
|
|
46
|
+
try:
|
|
47
|
+
data = json.loads(raw)
|
|
48
|
+
except (ValueError, UnicodeDecodeError):
|
|
49
|
+
pass
|
|
50
|
+
if status != 200 or not isinstance(data, dict):
|
|
51
|
+
code = data.get("error", "") if isinstance(data, dict) else ""
|
|
52
|
+
raise AuthenticationError(
|
|
53
|
+
status,
|
|
54
|
+
code if code in ("invalid_client", "invalid_grant", "temporarily_unavailable") else "",
|
|
55
|
+
)
|
|
56
|
+
token, lifetime, kind = (
|
|
57
|
+
data.get("access_token"),
|
|
58
|
+
data.get("expires_in", 900),
|
|
59
|
+
data.get("token_type", "Bearer"),
|
|
60
|
+
)
|
|
61
|
+
valid = isinstance(token, str) and isinstance(kind, str) and kind.lower() == "bearer"
|
|
62
|
+
try:
|
|
63
|
+
seconds = float(lifetime)
|
|
64
|
+
if not math.isfinite(seconds) or seconds <= 0 or isinstance(lifetime, bool):
|
|
65
|
+
valid = False
|
|
66
|
+
if valid and isinstance(token, str):
|
|
67
|
+
validate_token(token)
|
|
68
|
+
except (ValueError, TypeError, OverflowError):
|
|
69
|
+
valid = False
|
|
70
|
+
if not valid or not isinstance(token, str):
|
|
71
|
+
raise AuthenticationError(status)
|
|
72
|
+
return token, seconds - min(300, seconds * 0.1)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _request(key: str, secret: str, url: str, timeout: float) -> httpx.Request:
|
|
76
|
+
if not key or ":" in key or not secret:
|
|
77
|
+
raise ValueError("A valid access key ID and secret are required.")
|
|
78
|
+
encoded = base64.b64encode((key + ":" + secret).encode()).decode()
|
|
79
|
+
return httpx.Request(
|
|
80
|
+
"POST",
|
|
81
|
+
validate_url(url),
|
|
82
|
+
content=b"grant_type=client_credentials",
|
|
83
|
+
headers={
|
|
84
|
+
"Authorization": "Basic " + encoded,
|
|
85
|
+
"Accept": "application/json",
|
|
86
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
87
|
+
},
|
|
88
|
+
extensions={"timeout": httpx.Timeout(positive_timeout(timeout)).as_dict()},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ClientCredentials:
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
access_key_id: str,
|
|
97
|
+
secret_access_key: str,
|
|
98
|
+
token_url: str,
|
|
99
|
+
http_client: httpx.Client,
|
|
100
|
+
timeout: float = 30,
|
|
101
|
+
clock: Callable[[], float] = time.monotonic,
|
|
102
|
+
) -> None:
|
|
103
|
+
# Validate without retaining the prepared request, whose repr may expose headers.
|
|
104
|
+
_request(access_key_id, secret_access_key, token_url, timeout)
|
|
105
|
+
self.__key, self.__secret, self.__url = access_key_id, secret_access_key, token_url
|
|
106
|
+
self._http, self._timeout, self._clock = http_client, timeout, clock
|
|
107
|
+
self._lock = threading.Lock()
|
|
108
|
+
self.__cached, self._refresh_at = "", 0.0
|
|
109
|
+
|
|
110
|
+
def get_token(self) -> str:
|
|
111
|
+
with self._lock:
|
|
112
|
+
if self.__cached and self._clock() < self._refresh_at:
|
|
113
|
+
return self.__cached
|
|
114
|
+
now = self._clock()
|
|
115
|
+
failure: AuthenticationError | None = None
|
|
116
|
+
response: httpx.Response | None = None
|
|
117
|
+
try:
|
|
118
|
+
response = self._http.send(
|
|
119
|
+
_request(self.__key, self.__secret, self.__url, self._timeout),
|
|
120
|
+
auth=None,
|
|
121
|
+
follow_redirects=False,
|
|
122
|
+
stream=True,
|
|
123
|
+
)
|
|
124
|
+
raw = bytearray()
|
|
125
|
+
for chunk in response.iter_bytes():
|
|
126
|
+
raw.extend(chunk[: 1048577 - len(raw)])
|
|
127
|
+
if len(raw) > 1048576:
|
|
128
|
+
break
|
|
129
|
+
if len(raw) > 1048576:
|
|
130
|
+
raise AuthenticationError(response.status_code)
|
|
131
|
+
token, ttl = _token_response(response.status_code, bytes(raw))
|
|
132
|
+
except (httpx.HTTPError, AuthenticationError) as error:
|
|
133
|
+
failure = (
|
|
134
|
+
AuthenticationError(error.status_code, error.error_code)
|
|
135
|
+
if isinstance(error, AuthenticationError)
|
|
136
|
+
else AuthenticationError()
|
|
137
|
+
)
|
|
138
|
+
finally:
|
|
139
|
+
if response is not None:
|
|
140
|
+
response.close()
|
|
141
|
+
if failure is not None:
|
|
142
|
+
raise failure
|
|
143
|
+
self.__cached, self._refresh_at = token, now + ttl
|
|
144
|
+
return token
|
|
145
|
+
|
|
146
|
+
def invalidate(self, rejected_token: str | None = None) -> None:
|
|
147
|
+
with self._lock:
|
|
148
|
+
if rejected_token is None or rejected_token == self.__cached:
|
|
149
|
+
self.__cached, self._refresh_at = "", 0.0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class AsyncClientCredentials:
|
|
153
|
+
def __init__(
|
|
154
|
+
self,
|
|
155
|
+
*,
|
|
156
|
+
access_key_id: str,
|
|
157
|
+
secret_access_key: str,
|
|
158
|
+
token_url: str,
|
|
159
|
+
http_client: httpx.AsyncClient,
|
|
160
|
+
timeout: float = 30,
|
|
161
|
+
clock: Callable[[], float] = time.monotonic,
|
|
162
|
+
) -> None:
|
|
163
|
+
_request(access_key_id, secret_access_key, token_url, timeout)
|
|
164
|
+
self.__key, self.__secret, self.__url = access_key_id, secret_access_key, token_url
|
|
165
|
+
self._http, self._timeout, self._clock = http_client, timeout, clock
|
|
166
|
+
self.__cached, self._refresh_at = "", 0.0
|
|
167
|
+
self._pending: asyncio.Task[str] | None = None
|
|
168
|
+
|
|
169
|
+
async def get_token(self) -> str:
|
|
170
|
+
if self.__cached and self._clock() < self._refresh_at:
|
|
171
|
+
return self.__cached
|
|
172
|
+
# There is no await between checking and assigning the task; creation is
|
|
173
|
+
# atomic within the client's event loop. A caller's cancellation is isolated.
|
|
174
|
+
if self._pending is None:
|
|
175
|
+
task = asyncio.create_task(self._refresh())
|
|
176
|
+
self._pending = task
|
|
177
|
+
|
|
178
|
+
def done(finished: asyncio.Task[str]) -> None:
|
|
179
|
+
if self._pending is finished:
|
|
180
|
+
self._pending = None
|
|
181
|
+
if not finished.cancelled():
|
|
182
|
+
finished.exception()
|
|
183
|
+
|
|
184
|
+
task.add_done_callback(done)
|
|
185
|
+
return await asyncio.shield(self._pending)
|
|
186
|
+
|
|
187
|
+
def invalidate(self, rejected_token: str | None = None) -> None:
|
|
188
|
+
if rejected_token is None or rejected_token == self.__cached:
|
|
189
|
+
self.__cached, self._refresh_at = "", 0.0
|
|
190
|
+
|
|
191
|
+
async def aclose(self) -> None:
|
|
192
|
+
if self._pending is not None:
|
|
193
|
+
pending = self._pending
|
|
194
|
+
pending.cancel()
|
|
195
|
+
try:
|
|
196
|
+
await pending
|
|
197
|
+
except asyncio.CancelledError:
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
async def _refresh(self) -> str:
|
|
201
|
+
now = self._clock()
|
|
202
|
+
failure: AuthenticationError | None = None
|
|
203
|
+
response: httpx.Response | None = None
|
|
204
|
+
try:
|
|
205
|
+
response = await self._http.send(
|
|
206
|
+
_request(self.__key, self.__secret, self.__url, self._timeout),
|
|
207
|
+
auth=None,
|
|
208
|
+
follow_redirects=False,
|
|
209
|
+
stream=True,
|
|
210
|
+
)
|
|
211
|
+
raw = bytearray()
|
|
212
|
+
async for chunk in response.aiter_bytes():
|
|
213
|
+
raw.extend(chunk[: 1048577 - len(raw)])
|
|
214
|
+
if len(raw) > 1048576:
|
|
215
|
+
break
|
|
216
|
+
if len(raw) > 1048576:
|
|
217
|
+
raise AuthenticationError(response.status_code)
|
|
218
|
+
token, ttl = _token_response(response.status_code, bytes(raw))
|
|
219
|
+
except (httpx.HTTPError, AuthenticationError) as error:
|
|
220
|
+
failure = (
|
|
221
|
+
AuthenticationError(error.status_code, error.error_code)
|
|
222
|
+
if isinstance(error, AuthenticationError)
|
|
223
|
+
else AuthenticationError()
|
|
224
|
+
)
|
|
225
|
+
finally:
|
|
226
|
+
if response is not None:
|
|
227
|
+
await response.aclose()
|
|
228
|
+
if failure is not None:
|
|
229
|
+
raise failure
|
|
230
|
+
self.__cached, self._refresh_at = token, now + ttl
|
|
231
|
+
return token
|
basaltic/client.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Generated service accessors; do not edit."""
|
|
2
|
+
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
|
|
5
|
+
from ._client import AsyncClientBase, SyncClientBase
|
|
6
|
+
from .services.audit import AsyncAuditService, AuditService
|
|
7
|
+
from .services.billing import AsyncBillingService, BillingService
|
|
8
|
+
from .services.catalog import AsyncCatalogService, CatalogService
|
|
9
|
+
from .services.certificate import AsyncCertificateService, CertificateService
|
|
10
|
+
from .services.compute import AsyncComputeService, ComputeService
|
|
11
|
+
from .services.dns import AsyncDnsService, DnsService
|
|
12
|
+
from .services.iam import AsyncIamService, IamService
|
|
13
|
+
from .services.kms import AsyncKmsService, KmsService
|
|
14
|
+
from .services.loadbalancer import AsyncLoadbalancerService, LoadbalancerService
|
|
15
|
+
from .services.network import AsyncNetworkService, NetworkService
|
|
16
|
+
from .services.quota import AsyncQuotaService, QuotaService
|
|
17
|
+
from .services.secrets import AsyncSecretsService, SecretsService
|
|
18
|
+
from .services.storage import AsyncStorageService, StorageService
|
|
19
|
+
from .services.telemetry import AsyncTelemetryService, TelemetryService
|
|
20
|
+
from .services.workspace import AsyncWorkspaceService, WorkspaceService
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Client(SyncClientBase):
|
|
24
|
+
@cached_property
|
|
25
|
+
def audit(self) -> AuditService:
|
|
26
|
+
return AuditService(self._transport)
|
|
27
|
+
|
|
28
|
+
@cached_property
|
|
29
|
+
def billing(self) -> BillingService:
|
|
30
|
+
return BillingService(self._transport)
|
|
31
|
+
|
|
32
|
+
@cached_property
|
|
33
|
+
def catalog(self) -> CatalogService:
|
|
34
|
+
return CatalogService(self._transport)
|
|
35
|
+
|
|
36
|
+
@cached_property
|
|
37
|
+
def certificate(self) -> CertificateService:
|
|
38
|
+
return CertificateService(self._transport)
|
|
39
|
+
|
|
40
|
+
@cached_property
|
|
41
|
+
def compute(self) -> ComputeService:
|
|
42
|
+
return ComputeService(self._transport)
|
|
43
|
+
|
|
44
|
+
@cached_property
|
|
45
|
+
def dns(self) -> DnsService:
|
|
46
|
+
return DnsService(self._transport)
|
|
47
|
+
|
|
48
|
+
@cached_property
|
|
49
|
+
def iam(self) -> IamService:
|
|
50
|
+
return IamService(self._transport)
|
|
51
|
+
|
|
52
|
+
@cached_property
|
|
53
|
+
def kms(self) -> KmsService:
|
|
54
|
+
return KmsService(self._transport)
|
|
55
|
+
|
|
56
|
+
@cached_property
|
|
57
|
+
def loadbalancer(self) -> LoadbalancerService:
|
|
58
|
+
return LoadbalancerService(self._transport)
|
|
59
|
+
|
|
60
|
+
@cached_property
|
|
61
|
+
def network(self) -> NetworkService:
|
|
62
|
+
return NetworkService(self._transport)
|
|
63
|
+
|
|
64
|
+
@cached_property
|
|
65
|
+
def quota(self) -> QuotaService:
|
|
66
|
+
return QuotaService(self._transport)
|
|
67
|
+
|
|
68
|
+
@cached_property
|
|
69
|
+
def secrets(self) -> SecretsService:
|
|
70
|
+
return SecretsService(self._transport)
|
|
71
|
+
|
|
72
|
+
@cached_property
|
|
73
|
+
def storage(self) -> StorageService:
|
|
74
|
+
return StorageService(self._transport)
|
|
75
|
+
|
|
76
|
+
@cached_property
|
|
77
|
+
def telemetry(self) -> TelemetryService:
|
|
78
|
+
return TelemetryService(self._transport)
|
|
79
|
+
|
|
80
|
+
@cached_property
|
|
81
|
+
def workspace(self) -> WorkspaceService:
|
|
82
|
+
return WorkspaceService(self._transport)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class AsyncClient(AsyncClientBase):
|
|
86
|
+
@cached_property
|
|
87
|
+
def audit(self) -> AsyncAuditService:
|
|
88
|
+
return AsyncAuditService(self._transport)
|
|
89
|
+
|
|
90
|
+
@cached_property
|
|
91
|
+
def billing(self) -> AsyncBillingService:
|
|
92
|
+
return AsyncBillingService(self._transport)
|
|
93
|
+
|
|
94
|
+
@cached_property
|
|
95
|
+
def catalog(self) -> AsyncCatalogService:
|
|
96
|
+
return AsyncCatalogService(self._transport)
|
|
97
|
+
|
|
98
|
+
@cached_property
|
|
99
|
+
def certificate(self) -> AsyncCertificateService:
|
|
100
|
+
return AsyncCertificateService(self._transport)
|
|
101
|
+
|
|
102
|
+
@cached_property
|
|
103
|
+
def compute(self) -> AsyncComputeService:
|
|
104
|
+
return AsyncComputeService(self._transport)
|
|
105
|
+
|
|
106
|
+
@cached_property
|
|
107
|
+
def dns(self) -> AsyncDnsService:
|
|
108
|
+
return AsyncDnsService(self._transport)
|
|
109
|
+
|
|
110
|
+
@cached_property
|
|
111
|
+
def iam(self) -> AsyncIamService:
|
|
112
|
+
return AsyncIamService(self._transport)
|
|
113
|
+
|
|
114
|
+
@cached_property
|
|
115
|
+
def kms(self) -> AsyncKmsService:
|
|
116
|
+
return AsyncKmsService(self._transport)
|
|
117
|
+
|
|
118
|
+
@cached_property
|
|
119
|
+
def loadbalancer(self) -> AsyncLoadbalancerService:
|
|
120
|
+
return AsyncLoadbalancerService(self._transport)
|
|
121
|
+
|
|
122
|
+
@cached_property
|
|
123
|
+
def network(self) -> AsyncNetworkService:
|
|
124
|
+
return AsyncNetworkService(self._transport)
|
|
125
|
+
|
|
126
|
+
@cached_property
|
|
127
|
+
def quota(self) -> AsyncQuotaService:
|
|
128
|
+
return AsyncQuotaService(self._transport)
|
|
129
|
+
|
|
130
|
+
@cached_property
|
|
131
|
+
def secrets(self) -> AsyncSecretsService:
|
|
132
|
+
return AsyncSecretsService(self._transport)
|
|
133
|
+
|
|
134
|
+
@cached_property
|
|
135
|
+
def storage(self) -> AsyncStorageService:
|
|
136
|
+
return AsyncStorageService(self._transport)
|
|
137
|
+
|
|
138
|
+
@cached_property
|
|
139
|
+
def telemetry(self) -> AsyncTelemetryService:
|
|
140
|
+
return AsyncTelemetryService(self._transport)
|
|
141
|
+
|
|
142
|
+
@cached_property
|
|
143
|
+
def workspace(self) -> AsyncWorkspaceService:
|
|
144
|
+
return AsyncWorkspaceService(self._transport)
|
basaltic/config.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Explicit and environment-based configuration shared by all services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from types import MappingProxyType
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .auth import AsyncTokenProvider, TokenProvider
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def validate_url(value: str) -> str:
|
|
20
|
+
try:
|
|
21
|
+
u = urlsplit(value)
|
|
22
|
+
valid = u.scheme in ("http", "https") and u.hostname and u.port != 0
|
|
23
|
+
except ValueError:
|
|
24
|
+
valid = False
|
|
25
|
+
if (
|
|
26
|
+
not valid
|
|
27
|
+
or u.username
|
|
28
|
+
or u.password
|
|
29
|
+
or u.query
|
|
30
|
+
or u.fragment
|
|
31
|
+
or re.search(r"[\s\x00-\x1f\x7f]", value)
|
|
32
|
+
):
|
|
33
|
+
raise ValueError(
|
|
34
|
+
"Endpoint must be an absolute HTTP(S) URL without credentials, query or fragment."
|
|
35
|
+
)
|
|
36
|
+
return value.rstrip("/")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_token(value: str) -> str:
|
|
40
|
+
if (
|
|
41
|
+
not isinstance(value, str)
|
|
42
|
+
or not value
|
|
43
|
+
or not value.isascii()
|
|
44
|
+
or re.search(r"[\s\x00-\x1f\x7f]", value)
|
|
45
|
+
):
|
|
46
|
+
raise ValueError("A nonempty bearer token without whitespace is required.")
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def positive_timeout(value: float) -> float:
|
|
51
|
+
if isinstance(value, bool) or not math.isfinite(value) or value <= 0:
|
|
52
|
+
raise ValueError("timeout must be a finite positive number of seconds.")
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def attempt_count(value: int) -> int:
|
|
57
|
+
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 10:
|
|
58
|
+
raise ValueError("max_attempts must be an integer between 1 and 10.")
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, kw_only=True)
|
|
63
|
+
class RequestOptions:
|
|
64
|
+
account_id: str | None = None
|
|
65
|
+
idempotency_key: str | None = None
|
|
66
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
67
|
+
timeout: float | None = None
|
|
68
|
+
max_attempts: int | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, kw_only=True)
|
|
72
|
+
class Config:
|
|
73
|
+
access_key_id: str | None = field(default=None, repr=False)
|
|
74
|
+
secret_access_key: str | None = field(default=None, repr=False)
|
|
75
|
+
access_token: str | None = field(default=None, repr=False)
|
|
76
|
+
token_provider: TokenProvider | AsyncTokenProvider | None = field(default=None, repr=False)
|
|
77
|
+
anonymous: bool = False
|
|
78
|
+
region: str | None = None
|
|
79
|
+
account_id: str | None = None
|
|
80
|
+
domain: str | None = None
|
|
81
|
+
endpoints: Mapping[str, str] = field(default_factory=dict)
|
|
82
|
+
token_url: str | None = None
|
|
83
|
+
read_environment: bool = True
|
|
84
|
+
timeout: float = 30.0
|
|
85
|
+
max_attempts: int = 4
|
|
86
|
+
base_delay: float = 0.2
|
|
87
|
+
max_delay: float = 20.0
|
|
88
|
+
|
|
89
|
+
def __post_init__(self) -> None:
|
|
90
|
+
env = os.environ if self.read_environment else {}
|
|
91
|
+
for key in ("region", "account_id", "domain"):
|
|
92
|
+
if getattr(self, key) is None:
|
|
93
|
+
object.__setattr__(
|
|
94
|
+
self,
|
|
95
|
+
key,
|
|
96
|
+
env.get("BASALTIC_" + key.upper(), "basaltic.sh" if key == "domain" else ""),
|
|
97
|
+
)
|
|
98
|
+
if not re.fullmatch(
|
|
99
|
+
r"(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?",
|
|
100
|
+
self.domain or "",
|
|
101
|
+
):
|
|
102
|
+
raise ValueError("Invalid API domain.")
|
|
103
|
+
endpoints = {
|
|
104
|
+
k.removeprefix("BASALTIC_ENDPOINT_URL_").lower(): v
|
|
105
|
+
for k, v in env.items()
|
|
106
|
+
if k.startswith("BASALTIC_ENDPOINT_URL_") and v
|
|
107
|
+
}
|
|
108
|
+
endpoints.update(self.endpoints)
|
|
109
|
+
endpoints = {key: validate_url(value) for key, value in endpoints.items()}
|
|
110
|
+
object.__setattr__(self, "endpoints", MappingProxyType(endpoints))
|
|
111
|
+
explicit_keys = self.access_key_id is not None or self.secret_access_key is not None
|
|
112
|
+
if self.access_token is None:
|
|
113
|
+
object.__setattr__(
|
|
114
|
+
self, "access_token", "" if explicit_keys else env.get("BASALTIC_ACCESS_TOKEN", "")
|
|
115
|
+
)
|
|
116
|
+
for key in ("access_key_id", "secret_access_key"):
|
|
117
|
+
if getattr(self, key) is None:
|
|
118
|
+
object.__setattr__(self, key, env.get("BASALTIC_" + key.upper(), ""))
|
|
119
|
+
if (
|
|
120
|
+
not self.anonymous
|
|
121
|
+
and not self.token_provider
|
|
122
|
+
and not self.access_token
|
|
123
|
+
and not (self.access_key_id and self.secret_access_key)
|
|
124
|
+
):
|
|
125
|
+
raise ValueError(
|
|
126
|
+
"Configure an access key pair, bearer token, token provider or explicit anonymous access."
|
|
127
|
+
)
|
|
128
|
+
if self.access_token:
|
|
129
|
+
validate_token(self.access_token)
|
|
130
|
+
positive_timeout(self.timeout)
|
|
131
|
+
attempt_count(self.max_attempts)
|
|
132
|
+
if any(not math.isfinite(v) or v < 0 for v in (self.base_delay, self.max_delay)):
|
|
133
|
+
raise ValueError("Retry delays must be finite and nonnegative.")
|
|
134
|
+
|
|
135
|
+
def endpoint(self, service: str, template: str) -> str:
|
|
136
|
+
if service in self.endpoints:
|
|
137
|
+
return self.endpoints[service]
|
|
138
|
+
if "{region}" in template and not re.fullmatch(
|
|
139
|
+
r"[a-z0-9]+(?:-[a-z0-9]+)*", self.region or ""
|
|
140
|
+
):
|
|
141
|
+
raise ValueError(f"A valid region is required for {service}.")
|
|
142
|
+
return validate_url(
|
|
143
|
+
template.replace("basaltic.sh", self.domain or "").replace(
|
|
144
|
+
"{region}", self.region or ""
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def new_idempotency_key() -> str:
|
|
150
|
+
"""Create once per logical mutation and reuse when retrying that mutation."""
|
|
151
|
+
return str(uuid4())
|
basaltic/errors.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Structured SDK errors; transport and OAuth failures never retain raw requests."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BasalticError(Exception):
|
|
7
|
+
"""Base class for SDK failures."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProtocolError(BasalticError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TransportError(BasalticError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RequestTimeoutError(TransportError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AuthenticationError(BasalticError):
|
|
23
|
+
def __init__(self, status_code: int = 0, error_code: str = "") -> None:
|
|
24
|
+
self.status_code = status_code
|
|
25
|
+
self.error_code = error_code
|
|
26
|
+
super().__init__(f"Token exchange failed (HTTP {status_code}, code {error_code}).")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AmbiguousReferenceError(BasalticError):
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
super().__init__("Reference matches multiple resources; narrow its parent scope.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ApiError(BasalticError):
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
status_code: int,
|
|
38
|
+
error_code: str,
|
|
39
|
+
message: str,
|
|
40
|
+
request_id: str,
|
|
41
|
+
operation_id: str,
|
|
42
|
+
headers: dict[str, str] | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.status_code = status_code
|
|
45
|
+
self.error_code = error_code
|
|
46
|
+
self.request_id = request_id
|
|
47
|
+
self.operation_id = operation_id
|
|
48
|
+
self.headers = dict(headers or {})
|
|
49
|
+
super().__init__(f"{operation_id}: {message} (HTTP {status_code}, request {request_id})")
|
|
50
|
+
|
|
51
|
+
def is_not_found(self) -> bool:
|
|
52
|
+
return self.status_code in (404, 410)
|
|
53
|
+
|
|
54
|
+
def is_unauthorized(self) -> bool:
|
|
55
|
+
return self.status_code == 401
|
|
56
|
+
|
|
57
|
+
def is_quota_exceeded(self) -> bool:
|
|
58
|
+
return bool(re.search(r"(?:^|_)(?:QUOTA|LIMIT)_EXCEEDED$", self.error_code))
|
|
59
|
+
|
|
60
|
+
def is_access_denied(self) -> bool:
|
|
61
|
+
return self.status_code == 403 and not self.is_quota_exceeded()
|
|
62
|
+
|
|
63
|
+
def is_conflict(self) -> bool:
|
|
64
|
+
return self.status_code == 409
|
|
65
|
+
|
|
66
|
+
def is_invalid_input(self) -> bool:
|
|
67
|
+
return self.status_code in (400, 422)
|
|
68
|
+
|
|
69
|
+
def is_rate_limited(self) -> bool:
|
|
70
|
+
return self.status_code == 429
|
|
71
|
+
|
|
72
|
+
def is_transient(self) -> bool:
|
|
73
|
+
return self.status_code == 429 or self.status_code >= 500
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Service-specific typed request and response models."""
|
|
2
|
+
|
|
3
|
+
from . import audit as audit
|
|
4
|
+
from . import billing as billing
|
|
5
|
+
from . import catalog as catalog
|
|
6
|
+
from . import certificate as certificate
|
|
7
|
+
from . import compute as compute
|
|
8
|
+
from . import dns as dns
|
|
9
|
+
from . import iam as iam
|
|
10
|
+
from . import kms as kms
|
|
11
|
+
from . import loadbalancer as loadbalancer
|
|
12
|
+
from . import network as network
|
|
13
|
+
from . import quota as quota
|
|
14
|
+
from . import secrets as secrets
|
|
15
|
+
from . import storage as storage
|
|
16
|
+
from . import telemetry as telemetry
|
|
17
|
+
from . import workspace as workspace
|