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.
@@ -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