superartifacts 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.
- superartifacts/__init__.py +39 -0
- superartifacts/_client.py +280 -0
- superartifacts/_errors.py +125 -0
- superartifacts/_generated/__init__.py +0 -0
- superartifacts/_generated/models.py +1353 -0
- superartifacts/_generated/resources.py +3286 -0
- superartifacts/_helpers.py +195 -0
- superartifacts/py.typed +0 -0
- superartifacts-0.1.0.dist-info/METADATA +241 -0
- superartifacts-0.1.0.dist-info/RECORD +12 -0
- superartifacts-0.1.0.dist-info/WHEEL +4 -0
- superartifacts-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Python SDK for the Super Artifacts REST API. https://superartifacts.app/docs/sdks/python"""
|
|
2
|
+
|
|
3
|
+
from ._client import AsyncSuperArtifacts, SuperArtifacts, __version__
|
|
4
|
+
from ._errors import (
|
|
5
|
+
APIConnectionError,
|
|
6
|
+
APITimeoutError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
ConflictError,
|
|
9
|
+
InternalError,
|
|
10
|
+
InvalidArgumentsError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
PayloadTooLargeError,
|
|
13
|
+
PermissionError,
|
|
14
|
+
PlanLimitError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
ScopeRequiredError,
|
|
17
|
+
SuperArtifactsError,
|
|
18
|
+
)
|
|
19
|
+
from ._generated import models
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"APIConnectionError",
|
|
23
|
+
"APITimeoutError",
|
|
24
|
+
"AsyncSuperArtifacts",
|
|
25
|
+
"AuthenticationError",
|
|
26
|
+
"ConflictError",
|
|
27
|
+
"InternalError",
|
|
28
|
+
"InvalidArgumentsError",
|
|
29
|
+
"NotFoundError",
|
|
30
|
+
"PayloadTooLargeError",
|
|
31
|
+
"PermissionError",
|
|
32
|
+
"PlanLimitError",
|
|
33
|
+
"RateLimitError",
|
|
34
|
+
"ScopeRequiredError",
|
|
35
|
+
"SuperArtifacts",
|
|
36
|
+
"SuperArtifactsError",
|
|
37
|
+
"__version__",
|
|
38
|
+
"models",
|
|
39
|
+
]
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""The two clients. One retry policy, written once, driven by two small loops."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
11
|
+
from itertools import count
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from ._errors import (
|
|
18
|
+
APIConnectionError,
|
|
19
|
+
APITimeoutError,
|
|
20
|
+
AuthenticationError,
|
|
21
|
+
error_from,
|
|
22
|
+
parse_seconds,
|
|
23
|
+
)
|
|
24
|
+
from ._generated.resources import AsyncResources, SyncResources
|
|
25
|
+
from ._helpers import OMIT
|
|
26
|
+
|
|
27
|
+
DEFAULT_BASE_URL = "https://superartifacts.app"
|
|
28
|
+
MAX_RETRY_AFTER = 60.0
|
|
29
|
+
BACKOFF_BASE = 0.5
|
|
30
|
+
BACKOFF_MAX = 8.0
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
__version__ = version("superartifacts")
|
|
34
|
+
except PackageNotFoundError:
|
|
35
|
+
# Vendored without its metadata; only the User-Agent reads this.
|
|
36
|
+
__version__ = "0.0.0"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _backoff(attempt: int) -> float:
|
|
40
|
+
"""Exponential, jittered over the top half: 0.25-0.5 s, 0.5-1 s, ..., 4-8 s."""
|
|
41
|
+
ceiling = min(BACKOFF_BASE * 2**attempt, BACKOFF_MAX)
|
|
42
|
+
return random.uniform(ceiling / 2, ceiling)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _connection_error(exc: httpx.TransportError) -> APIConnectionError:
|
|
46
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
47
|
+
return APITimeoutError(
|
|
48
|
+
"The request timed out. Raise `timeout`, or try again.", status=0, code="timeout"
|
|
49
|
+
)
|
|
50
|
+
return APIConnectionError(
|
|
51
|
+
f"Could not reach the API ({type(exc).__name__}). Try again.",
|
|
52
|
+
status=0,
|
|
53
|
+
code="connection_error",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _answer(response: httpx.Response) -> dict[str, Any]:
|
|
58
|
+
if response.status_code == 304:
|
|
59
|
+
return {"etag": response.headers.get("etag"), "notModified": True}
|
|
60
|
+
# An upload PUT may answer with no JSON at all; its status said what mattered.
|
|
61
|
+
try:
|
|
62
|
+
body = response.json()
|
|
63
|
+
except ValueError:
|
|
64
|
+
return {}
|
|
65
|
+
answer = body if isinstance(body, dict) else {"data": body}
|
|
66
|
+
if "etag" in response.headers:
|
|
67
|
+
answer.update(etag=response.headers["etag"], notModified=False)
|
|
68
|
+
return answer
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _Base:
|
|
72
|
+
def __init__(self, api_key: str | None, base_url: str, timeout: float, max_retries: int):
|
|
73
|
+
key = api_key or os.environ.get("SUPERARTIFACTS_API_KEY")
|
|
74
|
+
if not key:
|
|
75
|
+
raise AuthenticationError(
|
|
76
|
+
"No API key. Pass api_key= or set SUPERARTIFACTS_API_KEY. "
|
|
77
|
+
"Create one at https://superartifacts.app/settings/api-keys.",
|
|
78
|
+
status=0,
|
|
79
|
+
code="missing_api_key",
|
|
80
|
+
)
|
|
81
|
+
self._api_key = key
|
|
82
|
+
self.base_url = base_url.rstrip("/")
|
|
83
|
+
self.timeout = timeout
|
|
84
|
+
self.max_retries = max_retries
|
|
85
|
+
|
|
86
|
+
def _prepare(
|
|
87
|
+
self,
|
|
88
|
+
method: str,
|
|
89
|
+
path: str,
|
|
90
|
+
path_params: dict[str, Any] | None,
|
|
91
|
+
query: dict[str, Any] | None,
|
|
92
|
+
body: dict[str, Any] | None,
|
|
93
|
+
nullable: dict[str, Any] | None,
|
|
94
|
+
idempotency_key: str | None,
|
|
95
|
+
) -> tuple[str, dict[str, Any]]:
|
|
96
|
+
"""The URL and httpx arguments for one operation."""
|
|
97
|
+
for name, value in (path_params or {}).items():
|
|
98
|
+
path = path.replace("{" + name + "}", quote(str(value), safe=""))
|
|
99
|
+
headers = {
|
|
100
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
101
|
+
"Accept": "application/json",
|
|
102
|
+
"User-Agent": f"superartifacts-python/{__version__}",
|
|
103
|
+
}
|
|
104
|
+
# Made here, once, so every retry of this call carries the same key.
|
|
105
|
+
if method == "POST":
|
|
106
|
+
headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
|
|
107
|
+
parts: dict[str, Any] = {"headers": headers}
|
|
108
|
+
if query:
|
|
109
|
+
parts["params"] = {k: v for k, v in query.items() if v is not None}
|
|
110
|
+
if body is not None:
|
|
111
|
+
parts["json"] = {k: v for k, v in body.items() if v is not None}
|
|
112
|
+
parts["json"].update({k: v for k, v in (nullable or {}).items() if v is not OMIT})
|
|
113
|
+
return self.base_url + path, parts
|
|
114
|
+
|
|
115
|
+
def _retry_delay(
|
|
116
|
+
self,
|
|
117
|
+
method: str,
|
|
118
|
+
parts: dict[str, Any],
|
|
119
|
+
attempt: int,
|
|
120
|
+
status: int | None = None,
|
|
121
|
+
retry_after: str | None = None,
|
|
122
|
+
) -> float | None:
|
|
123
|
+
"""Seconds to wait before another try, or None to stop. `status` None is a network error."""
|
|
124
|
+
if attempt >= self.max_retries:
|
|
125
|
+
return None
|
|
126
|
+
if status == 429:
|
|
127
|
+
seconds = parse_seconds(retry_after)
|
|
128
|
+
if seconds is None:
|
|
129
|
+
return _backoff(attempt)
|
|
130
|
+
# Past a minute (the hourly email limit) waiting it out would hang the caller:
|
|
131
|
+
# the RateLimitError comes straight back with retry_after set, as in the TypeScript SDK.
|
|
132
|
+
return seconds if seconds <= MAX_RETRY_AFTER else None
|
|
133
|
+
if status not in (None, 502, 503, 504):
|
|
134
|
+
return None
|
|
135
|
+
safe = method in ("GET", "DELETE") or "Idempotency-Key" in parts.get("headers", {})
|
|
136
|
+
return _backoff(attempt) if safe else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class SuperArtifacts(_Base, SyncResources):
|
|
140
|
+
"""Synchronous client. Use it as a context manager, or call `close()`."""
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
api_key: str | None = None,
|
|
145
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
146
|
+
timeout: float = 60.0,
|
|
147
|
+
max_retries: int = 2,
|
|
148
|
+
http_client: httpx.Client | None = None,
|
|
149
|
+
) -> None:
|
|
150
|
+
_Base.__init__(self, api_key, base_url, timeout, max_retries)
|
|
151
|
+
SyncResources.__init__(self)
|
|
152
|
+
self._owns_http = http_client is None
|
|
153
|
+
self._http = http_client or httpx.Client()
|
|
154
|
+
|
|
155
|
+
def _call(
|
|
156
|
+
self,
|
|
157
|
+
method: str,
|
|
158
|
+
path: str,
|
|
159
|
+
*,
|
|
160
|
+
path_params: dict[str, Any] | None = None,
|
|
161
|
+
query: dict[str, Any] | None = None,
|
|
162
|
+
body: dict[str, Any] | None = None,
|
|
163
|
+
nullable: dict[str, Any] | None = None,
|
|
164
|
+
idempotency_key: str | None = None,
|
|
165
|
+
headers: dict[str, Any] | None = None,
|
|
166
|
+
timeout: float | None = None,
|
|
167
|
+
) -> dict[str, Any]:
|
|
168
|
+
url, parts = self._prepare(
|
|
169
|
+
method, path, path_params, query, body, nullable, idempotency_key
|
|
170
|
+
)
|
|
171
|
+
parts["headers"].update({k: v for k, v in (headers or {}).items() if v is not None})
|
|
172
|
+
return self._request(method, url, timeout, parts)
|
|
173
|
+
|
|
174
|
+
def _request(
|
|
175
|
+
self, method: str, url: str, timeout: float | None, parts: dict[str, Any]
|
|
176
|
+
) -> dict[str, Any]:
|
|
177
|
+
wait = self.timeout if timeout is None else timeout
|
|
178
|
+
for attempt in count():
|
|
179
|
+
try:
|
|
180
|
+
response = self._http.request(method, url, timeout=wait, **parts)
|
|
181
|
+
except httpx.TransportError as exc:
|
|
182
|
+
delay = self._retry_delay(method, parts, attempt)
|
|
183
|
+
if delay is None:
|
|
184
|
+
raise _connection_error(exc) from exc
|
|
185
|
+
else:
|
|
186
|
+
if response.is_success or response.status_code == 304:
|
|
187
|
+
return _answer(response)
|
|
188
|
+
delay = self._retry_delay(
|
|
189
|
+
method,
|
|
190
|
+
parts,
|
|
191
|
+
attempt,
|
|
192
|
+
response.status_code,
|
|
193
|
+
response.headers.get("retry-after"),
|
|
194
|
+
)
|
|
195
|
+
if delay is None:
|
|
196
|
+
raise error_from(response)
|
|
197
|
+
time.sleep(delay)
|
|
198
|
+
|
|
199
|
+
def close(self) -> None:
|
|
200
|
+
"""Close the HTTP client, unless it was passed in."""
|
|
201
|
+
if self._owns_http:
|
|
202
|
+
self._http.close()
|
|
203
|
+
|
|
204
|
+
def __enter__(self) -> SuperArtifacts:
|
|
205
|
+
return self
|
|
206
|
+
|
|
207
|
+
def __exit__(self, *_: object) -> None:
|
|
208
|
+
self.close()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class AsyncSuperArtifacts(_Base, AsyncResources):
|
|
212
|
+
"""Asynchronous client, the same surface awaited. `async with`, or `await close()`."""
|
|
213
|
+
|
|
214
|
+
def __init__(
|
|
215
|
+
self,
|
|
216
|
+
api_key: str | None = None,
|
|
217
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
218
|
+
timeout: float = 60.0,
|
|
219
|
+
max_retries: int = 2,
|
|
220
|
+
http_client: httpx.AsyncClient | None = None,
|
|
221
|
+
) -> None:
|
|
222
|
+
_Base.__init__(self, api_key, base_url, timeout, max_retries)
|
|
223
|
+
AsyncResources.__init__(self)
|
|
224
|
+
self._owns_http = http_client is None
|
|
225
|
+
self._http = http_client or httpx.AsyncClient()
|
|
226
|
+
|
|
227
|
+
async def _call(
|
|
228
|
+
self,
|
|
229
|
+
method: str,
|
|
230
|
+
path: str,
|
|
231
|
+
*,
|
|
232
|
+
path_params: dict[str, Any] | None = None,
|
|
233
|
+
query: dict[str, Any] | None = None,
|
|
234
|
+
body: dict[str, Any] | None = None,
|
|
235
|
+
nullable: dict[str, Any] | None = None,
|
|
236
|
+
idempotency_key: str | None = None,
|
|
237
|
+
headers: dict[str, Any] | None = None,
|
|
238
|
+
timeout: float | None = None,
|
|
239
|
+
) -> dict[str, Any]:
|
|
240
|
+
url, parts = self._prepare(
|
|
241
|
+
method, path, path_params, query, body, nullable, idempotency_key
|
|
242
|
+
)
|
|
243
|
+
parts["headers"].update({k: v for k, v in (headers or {}).items() if v is not None})
|
|
244
|
+
return await self._request(method, url, timeout, parts)
|
|
245
|
+
|
|
246
|
+
async def _request(
|
|
247
|
+
self, method: str, url: str, timeout: float | None, parts: dict[str, Any]
|
|
248
|
+
) -> dict[str, Any]:
|
|
249
|
+
wait = self.timeout if timeout is None else timeout
|
|
250
|
+
for attempt in count():
|
|
251
|
+
try:
|
|
252
|
+
response = await self._http.request(method, url, timeout=wait, **parts)
|
|
253
|
+
except httpx.TransportError as exc:
|
|
254
|
+
delay = self._retry_delay(method, parts, attempt)
|
|
255
|
+
if delay is None:
|
|
256
|
+
raise _connection_error(exc) from exc
|
|
257
|
+
else:
|
|
258
|
+
if response.is_success or response.status_code == 304:
|
|
259
|
+
return _answer(response)
|
|
260
|
+
delay = self._retry_delay(
|
|
261
|
+
method,
|
|
262
|
+
parts,
|
|
263
|
+
attempt,
|
|
264
|
+
response.status_code,
|
|
265
|
+
response.headers.get("retry-after"),
|
|
266
|
+
)
|
|
267
|
+
if delay is None:
|
|
268
|
+
raise error_from(response)
|
|
269
|
+
await asyncio.sleep(delay)
|
|
270
|
+
|
|
271
|
+
async def close(self) -> None:
|
|
272
|
+
"""Close the HTTP client, unless it was passed in."""
|
|
273
|
+
if self._owns_http:
|
|
274
|
+
await self._http.aclose()
|
|
275
|
+
|
|
276
|
+
async def __aenter__(self) -> AsyncSuperArtifacts:
|
|
277
|
+
return self
|
|
278
|
+
|
|
279
|
+
async def __aexit__(self, *_: object) -> None:
|
|
280
|
+
await self.close()
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""What a refused or failed call raises."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SuperArtifactsError(Exception):
|
|
11
|
+
"""Base of every error here. `code` is stable; `message` is for a person."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
message: str,
|
|
16
|
+
*,
|
|
17
|
+
status: int | None = None,
|
|
18
|
+
code: str | None = None,
|
|
19
|
+
retry_after: float | None = None,
|
|
20
|
+
details: dict[str, Any] | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.message = message
|
|
24
|
+
self.status = status
|
|
25
|
+
self.code = code
|
|
26
|
+
self.retry_after = retry_after
|
|
27
|
+
self.details = details or {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InvalidArgumentsError(SuperArtifactsError):
|
|
31
|
+
"""400: the request was refused as written."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AuthenticationError(SuperArtifactsError):
|
|
35
|
+
"""401, or no key to send."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PlanLimitError(SuperArtifactsError):
|
|
39
|
+
"""402: the account's plan does not allow this."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PermissionError(SuperArtifactsError):
|
|
43
|
+
"""403 forbidden."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ScopeRequiredError(PermissionError):
|
|
47
|
+
"""403 scope_required: a read key called a write."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NotFoundError(SuperArtifactsError):
|
|
51
|
+
"""404."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ConflictError(SuperArtifactsError):
|
|
55
|
+
"""409."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PayloadTooLargeError(SuperArtifactsError):
|
|
59
|
+
"""413: over 1 MiB of JSON. Use `uploads.upload` for files."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RateLimitError(SuperArtifactsError):
|
|
63
|
+
"""429 after the retries ran out. `retry_after` is in seconds."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class InternalError(SuperArtifactsError):
|
|
67
|
+
"""5xx."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class APIConnectionError(SuperArtifactsError):
|
|
71
|
+
"""The request never got an answer."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class APITimeoutError(APIConnectionError):
|
|
75
|
+
"""The request took longer than `timeout`."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_BY_STATUS = {
|
|
79
|
+
400: InvalidArgumentsError,
|
|
80
|
+
401: AuthenticationError,
|
|
81
|
+
402: PlanLimitError,
|
|
82
|
+
403: PermissionError,
|
|
83
|
+
404: NotFoundError,
|
|
84
|
+
409: ConflictError,
|
|
85
|
+
413: PayloadTooLargeError,
|
|
86
|
+
429: RateLimitError,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_seconds(value: Any) -> float | None:
|
|
91
|
+
"""A Retry-After in seconds; the HTTP-date form and junk read as absent."""
|
|
92
|
+
try:
|
|
93
|
+
seconds = float(value)
|
|
94
|
+
except (TypeError, ValueError):
|
|
95
|
+
return None
|
|
96
|
+
return seconds if seconds >= 0 else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def error_from(response: httpx.Response) -> SuperArtifactsError:
|
|
100
|
+
"""The error for a non-2xx answer, whatever its body turned out to be."""
|
|
101
|
+
try:
|
|
102
|
+
body = response.json()
|
|
103
|
+
except ValueError:
|
|
104
|
+
# A proxy's HTML page rather than the API: the status is all there is.
|
|
105
|
+
body = None
|
|
106
|
+
error = body.get("error") if isinstance(body, dict) else None
|
|
107
|
+
error = error if isinstance(error, dict) else {}
|
|
108
|
+
|
|
109
|
+
status = response.status_code
|
|
110
|
+
code = error.get("code") if isinstance(error.get("code"), str) else None
|
|
111
|
+
message = error.get("message")
|
|
112
|
+
if not isinstance(message, str):
|
|
113
|
+
message = f"The API answered {status} {response.reason_phrase}".strip()
|
|
114
|
+
|
|
115
|
+
if status == 403 and code == "scope_required":
|
|
116
|
+
kind = ScopeRequiredError
|
|
117
|
+
elif status >= 500:
|
|
118
|
+
kind = InternalError
|
|
119
|
+
else:
|
|
120
|
+
kind = _BY_STATUS.get(status, SuperArtifactsError)
|
|
121
|
+
|
|
122
|
+
retry_after = parse_seconds(response.headers.get("retry-after"))
|
|
123
|
+
if retry_after is None:
|
|
124
|
+
retry_after = parse_seconds(error.get("retryAfterSeconds"))
|
|
125
|
+
return kind(message, status=status, code=code, retry_after=retry_after, details=error)
|
|
File without changes
|