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/__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