medalbot-render 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.
- medalbot_render/__init__.py +23 -0
- medalbot_render/client.py +329 -0
- medalbot_render/models.py +86 -0
- medalbot_render/py.typed +0 -0
- medalbot_render-0.1.1.dist-info/METADATA +98 -0
- medalbot_render-0.1.1.dist-info/RECORD +8 -0
- medalbot_render-0.1.1.dist-info/WHEEL +4 -0
- medalbot_render-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .client import RenderClient
|
|
2
|
+
from .models import (
|
|
3
|
+
CompletedRender,
|
|
4
|
+
FailedRender,
|
|
5
|
+
Format,
|
|
6
|
+
Images,
|
|
7
|
+
PendingRender,
|
|
8
|
+
Render,
|
|
9
|
+
RenderError,
|
|
10
|
+
View,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"CompletedRender",
|
|
15
|
+
"FailedRender",
|
|
16
|
+
"Format",
|
|
17
|
+
"Images",
|
|
18
|
+
"PendingRender",
|
|
19
|
+
"Render",
|
|
20
|
+
"RenderError",
|
|
21
|
+
"RenderClient",
|
|
22
|
+
"View",
|
|
23
|
+
]
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import math
|
|
5
|
+
import random
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from email.utils import parsedate_to_datetime
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
from typing import Self, TypeVar
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from pydantic import ValidationError
|
|
17
|
+
|
|
18
|
+
from .models import (
|
|
19
|
+
CompletedRender,
|
|
20
|
+
FailedRender,
|
|
21
|
+
Format,
|
|
22
|
+
Images,
|
|
23
|
+
Render,
|
|
24
|
+
RenderError,
|
|
25
|
+
RenderResponse,
|
|
26
|
+
View,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
_VIEWS: tuple[View, ...] = ("preview", "front", "back")
|
|
31
|
+
_RENDER_ID = re.compile(r"rnd_(?:[0-7][0-9A-HJKMNP-TV-Z]{25}|[a-f0-9]{32})\Z")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class _Context:
|
|
36
|
+
render_id: str | None = None
|
|
37
|
+
idempotency_key: str | None = None
|
|
38
|
+
request_id: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _validate_id(render_id: str) -> None:
|
|
42
|
+
if not _RENDER_ID.fullmatch(render_id):
|
|
43
|
+
raise ValueError("render_id must be a prefixed ULID or an existing render ID")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _views(views: Sequence[View] | None) -> tuple[View, ...]:
|
|
47
|
+
result = tuple(views) if views is not None else _VIEWS
|
|
48
|
+
if not result or any(view not in _VIEWS for view in result):
|
|
49
|
+
raise ValueError("views must contain preview, front, or back")
|
|
50
|
+
if len(set(result)) != len(result):
|
|
51
|
+
raise ValueError("views must be unique")
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _submission(
|
|
56
|
+
inspect_url: str,
|
|
57
|
+
views: Sequence[View] | None,
|
|
58
|
+
format: Format,
|
|
59
|
+
idempotency_key: str | None,
|
|
60
|
+
) -> tuple[dict[str, object], _Context, tuple[View, ...]]:
|
|
61
|
+
if not isinstance(inspect_url, str) or not 1 <= len(inspect_url) <= 32768:
|
|
62
|
+
raise ValueError("inspect_url must contain 1 to 32768 characters")
|
|
63
|
+
requested = _views(views)
|
|
64
|
+
if format not in ("webp", "png"):
|
|
65
|
+
raise ValueError("format must be webp or png")
|
|
66
|
+
key = str(uuid4()) if idempotency_key is None else idempotency_key
|
|
67
|
+
if not re.fullmatch(r"[\x21-\x7e]{1,128}", key):
|
|
68
|
+
raise ValueError(
|
|
69
|
+
"idempotency_key must contain 1 to 128 visible ASCII characters"
|
|
70
|
+
)
|
|
71
|
+
return (
|
|
72
|
+
{"inspectUrl": inspect_url, "views": list(requested), "format": format},
|
|
73
|
+
_Context(idempotency_key=key),
|
|
74
|
+
requested,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _retry_delay(response: httpx.Response | None, attempt: int) -> float:
|
|
79
|
+
retry_after = 0.0
|
|
80
|
+
if response is not None:
|
|
81
|
+
value = response.headers.get("Retry-After", "")
|
|
82
|
+
try:
|
|
83
|
+
retry_after = float(value)
|
|
84
|
+
except ValueError:
|
|
85
|
+
try:
|
|
86
|
+
date = parsedate_to_datetime(value)
|
|
87
|
+
if date.tzinfo is None:
|
|
88
|
+
date = date.replace(tzinfo=UTC)
|
|
89
|
+
retry_after = (date - datetime.now(UTC)).total_seconds()
|
|
90
|
+
except (ValueError, TypeError, OverflowError):
|
|
91
|
+
pass
|
|
92
|
+
if not math.isfinite(retry_after):
|
|
93
|
+
retry_after = 0.0
|
|
94
|
+
return min(10.0, max(0.2 * math.pow(2, attempt), retry_after)) + random.uniform(
|
|
95
|
+
0, 0.1
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _text(value: object) -> str | None:
|
|
100
|
+
return value if isinstance(value, str) and value else None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class RenderClient:
|
|
104
|
+
"""Async render client. Owns its HTTP connection pool; close after use."""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
api_key: str,
|
|
110
|
+
base_url: str = "https://render.medalbot.cc",
|
|
111
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
if not api_key.strip():
|
|
114
|
+
raise ValueError("api_key must not be empty")
|
|
115
|
+
self._http = httpx.AsyncClient(
|
|
116
|
+
base_url=base_url.rstrip("/"),
|
|
117
|
+
headers={
|
|
118
|
+
"Authorization": f"Bearer {api_key}",
|
|
119
|
+
"Accept": "application/json",
|
|
120
|
+
},
|
|
121
|
+
transport=transport,
|
|
122
|
+
timeout=httpx.Timeout(30, connect=10),
|
|
123
|
+
follow_redirects=False,
|
|
124
|
+
)
|
|
125
|
+
self.renders = Renders(self)
|
|
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()
|
|
137
|
+
|
|
138
|
+
async def aclose(self) -> None:
|
|
139
|
+
await self._http.aclose()
|
|
140
|
+
|
|
141
|
+
async def _bounded(
|
|
142
|
+
self,
|
|
143
|
+
operation: Callable[[], Awaitable[T]],
|
|
144
|
+
timeout: float,
|
|
145
|
+
context: _Context,
|
|
146
|
+
) -> T:
|
|
147
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
148
|
+
raise ValueError("timeout must be a positive, finite number of seconds")
|
|
149
|
+
deadline = asyncio.timeout(timeout)
|
|
150
|
+
try:
|
|
151
|
+
async with deadline:
|
|
152
|
+
return await operation()
|
|
153
|
+
except TimeoutError:
|
|
154
|
+
if not deadline.expired():
|
|
155
|
+
raise
|
|
156
|
+
raise RenderError(
|
|
157
|
+
"wait_timeout",
|
|
158
|
+
"Render continues; resume with renders.wait(render_id)"
|
|
159
|
+
if context.render_id
|
|
160
|
+
else "Submission timed out; retry with the same idempotency key",
|
|
161
|
+
render_id=context.render_id,
|
|
162
|
+
request_id=context.request_id,
|
|
163
|
+
idempotency_key=context.idempotency_key,
|
|
164
|
+
) from None
|
|
165
|
+
except RenderError as error:
|
|
166
|
+
error.render_id = error.render_id or context.render_id
|
|
167
|
+
error.idempotency_key = error.idempotency_key or context.idempotency_key
|
|
168
|
+
error.request_id = error.request_id or context.request_id
|
|
169
|
+
raise
|
|
170
|
+
|
|
171
|
+
async def _request(
|
|
172
|
+
self,
|
|
173
|
+
method: str,
|
|
174
|
+
path: str,
|
|
175
|
+
context: _Context,
|
|
176
|
+
*,
|
|
177
|
+
body: dict[str, object] | None = None,
|
|
178
|
+
wait: bool = False,
|
|
179
|
+
) -> Render:
|
|
180
|
+
headers = {}
|
|
181
|
+
if context.idempotency_key and method == "POST":
|
|
182
|
+
headers["Idempotency-Key"] = context.idempotency_key
|
|
183
|
+
if wait:
|
|
184
|
+
headers["Prefer"] = "wait=10"
|
|
185
|
+
for attempt in range(4):
|
|
186
|
+
try:
|
|
187
|
+
response = await self._http.request(
|
|
188
|
+
method, path, json=body, headers=headers
|
|
189
|
+
)
|
|
190
|
+
except httpx.TransportError:
|
|
191
|
+
if attempt == 3:
|
|
192
|
+
raise RenderError(
|
|
193
|
+
"transport_error", "API connection failed after four attempts"
|
|
194
|
+
) from None
|
|
195
|
+
await asyncio.sleep(_retry_delay(None, attempt))
|
|
196
|
+
continue
|
|
197
|
+
context.request_id = (
|
|
198
|
+
response.headers.get("x-request-id") or context.request_id
|
|
199
|
+
)
|
|
200
|
+
if response.status_code in (429, 502, 503, 504) and attempt < 3:
|
|
201
|
+
await asyncio.sleep(_retry_delay(response, attempt))
|
|
202
|
+
continue
|
|
203
|
+
try:
|
|
204
|
+
value = response.json()
|
|
205
|
+
except ValueError:
|
|
206
|
+
value = None
|
|
207
|
+
data = value if isinstance(value, dict) else {}
|
|
208
|
+
meta = data.get("meta")
|
|
209
|
+
if isinstance(meta, dict):
|
|
210
|
+
context.request_id = _text(meta.get("requestId")) or context.request_id
|
|
211
|
+
if not response.is_success:
|
|
212
|
+
nested = data.get("error")
|
|
213
|
+
error = nested if isinstance(nested, dict) else {}
|
|
214
|
+
raise RenderError(
|
|
215
|
+
_text(error.get("code")) or f"http_{response.status_code}",
|
|
216
|
+
_text(error.get("detail"))
|
|
217
|
+
or _text(error.get("message"))
|
|
218
|
+
or f"API returned HTTP {response.status_code}",
|
|
219
|
+
request_id=context.request_id,
|
|
220
|
+
status_code=response.status_code,
|
|
221
|
+
)
|
|
222
|
+
try:
|
|
223
|
+
envelope = RenderResponse.model_validate(value)
|
|
224
|
+
context.request_id = envelope.meta.request_id
|
|
225
|
+
result = envelope.data
|
|
226
|
+
if context.render_id and result.id != context.render_id:
|
|
227
|
+
raise ValueError("Response render ID changed")
|
|
228
|
+
except (ValidationError, ValueError):
|
|
229
|
+
raise RenderError(
|
|
230
|
+
"invalid_response", "API returned an invalid render response"
|
|
231
|
+
) from None
|
|
232
|
+
context.render_id = result.id
|
|
233
|
+
return result
|
|
234
|
+
raise AssertionError("Unreachable retry state")
|
|
235
|
+
|
|
236
|
+
async def _poll(
|
|
237
|
+
self,
|
|
238
|
+
context: _Context,
|
|
239
|
+
expected: tuple[View, ...] | None,
|
|
240
|
+
initial: Render | None = None,
|
|
241
|
+
) -> Images:
|
|
242
|
+
assert context.render_id is not None
|
|
243
|
+
result = initial or await self._request(
|
|
244
|
+
"GET", f"/v1/renders/{context.render_id}", context
|
|
245
|
+
)
|
|
246
|
+
interval = 0.5
|
|
247
|
+
while True:
|
|
248
|
+
if isinstance(result, FailedRender):
|
|
249
|
+
raise RenderError(result.error.code, result.error.detail)
|
|
250
|
+
if isinstance(result, CompletedRender):
|
|
251
|
+
for view in expected or ():
|
|
252
|
+
if view not in result.images:
|
|
253
|
+
raise RenderError(
|
|
254
|
+
"invalid_response", f"Completed render is missing {view}"
|
|
255
|
+
)
|
|
256
|
+
return result.images
|
|
257
|
+
await asyncio.sleep(interval)
|
|
258
|
+
result = await self._request(
|
|
259
|
+
"GET", f"/v1/renders/{context.render_id}", context
|
|
260
|
+
)
|
|
261
|
+
interval = min(2.0, interval * 1.5)
|
|
262
|
+
|
|
263
|
+
async def render(
|
|
264
|
+
self,
|
|
265
|
+
*,
|
|
266
|
+
inspect_url: str,
|
|
267
|
+
views: Sequence[View] | None = None,
|
|
268
|
+
format: Format = "webp",
|
|
269
|
+
timeout: float = 120,
|
|
270
|
+
idempotency_key: str | None = None,
|
|
271
|
+
) -> Images:
|
|
272
|
+
"""Return finished images, including cached results, within one total deadline."""
|
|
273
|
+
body, context, requested = _submission(
|
|
274
|
+
inspect_url, views, format, idempotency_key
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
async def run() -> Images:
|
|
278
|
+
result = await self._request(
|
|
279
|
+
"POST", "/v1/renders", context, body=body, wait=True
|
|
280
|
+
)
|
|
281
|
+
return await self._poll(context, requested, result)
|
|
282
|
+
|
|
283
|
+
return await self._bounded(run, timeout, context)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class Renders:
|
|
287
|
+
"""Lower-level admission, status, and resume operations."""
|
|
288
|
+
|
|
289
|
+
def __init__(self, client: RenderClient) -> None:
|
|
290
|
+
self._client = client
|
|
291
|
+
|
|
292
|
+
async def create(
|
|
293
|
+
self,
|
|
294
|
+
*,
|
|
295
|
+
inspect_url: str,
|
|
296
|
+
views: Sequence[View] | None = None,
|
|
297
|
+
format: Format = "webp",
|
|
298
|
+
timeout: float = 120,
|
|
299
|
+
idempotency_key: str | None = None,
|
|
300
|
+
) -> Render:
|
|
301
|
+
body, context, _ = _submission(inspect_url, views, format, idempotency_key)
|
|
302
|
+
return await self._client._bounded(
|
|
303
|
+
lambda: self._client._request("POST", "/v1/renders", context, body=body),
|
|
304
|
+
timeout,
|
|
305
|
+
context,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
async def get(self, render_id: str, *, timeout: float = 120) -> Render:
|
|
309
|
+
_validate_id(render_id)
|
|
310
|
+
context = _Context(render_id=render_id)
|
|
311
|
+
return await self._client._bounded(
|
|
312
|
+
lambda: self._client._request("GET", f"/v1/renders/{render_id}", context),
|
|
313
|
+
timeout,
|
|
314
|
+
context,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
async def wait(
|
|
318
|
+
self,
|
|
319
|
+
render_id: str,
|
|
320
|
+
*,
|
|
321
|
+
timeout: float = 120,
|
|
322
|
+
views: Sequence[View] | None = None,
|
|
323
|
+
) -> Images:
|
|
324
|
+
_validate_id(render_id)
|
|
325
|
+
context = _Context(render_id=render_id)
|
|
326
|
+
expected = _views(views) if views is not None else None
|
|
327
|
+
return await self._client._bounded(
|
|
328
|
+
lambda: self._client._poll(context, expected), timeout, context
|
|
329
|
+
)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import (
|
|
6
|
+
AfterValidator,
|
|
7
|
+
BaseModel,
|
|
8
|
+
ConfigDict,
|
|
9
|
+
Field,
|
|
10
|
+
HttpUrl,
|
|
11
|
+
StringConstraints,
|
|
12
|
+
TypeAdapter,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
View = Literal["preview", "front", "back"]
|
|
16
|
+
Format = Literal["png", "webp"]
|
|
17
|
+
RenderId = Annotated[str, StringConstraints(pattern=r"^rnd_(?:[0-7][0-9A-HJKMNP-TV-Z]{25}|[a-f0-9]{32})$")]
|
|
18
|
+
Images = dict[View, str]
|
|
19
|
+
_url = TypeAdapter(HttpUrl)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _validate_url(value: str) -> str:
|
|
23
|
+
_url.validate_python(value)
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
ImageUrl = Annotated[str, AfterValidator(_validate_url)]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _Model(BaseModel):
|
|
31
|
+
model_config = ConfigDict(strict=True, frozen=True, extra="forbid")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Failure(_Model):
|
|
35
|
+
code: str
|
|
36
|
+
detail: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CompletedRender(_Model):
|
|
40
|
+
id: RenderId
|
|
41
|
+
images: dict[View, ImageUrl] = Field(min_length=1, max_length=3)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PendingRender(_Model):
|
|
45
|
+
id: RenderId
|
|
46
|
+
status: Literal["queued", "rendering", "processing"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class FailedRender(_Model):
|
|
50
|
+
id: RenderId
|
|
51
|
+
status: Literal["failed"]
|
|
52
|
+
error: Failure
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
Render = CompletedRender | PendingRender | FailedRender
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _Meta(_Model):
|
|
59
|
+
request_id: str = Field(alias="requestId", min_length=1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RenderResponse(_Model):
|
|
63
|
+
meta: _Meta
|
|
64
|
+
data: Render
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RenderError(Exception):
|
|
68
|
+
"""API, rendering, or local deadline error with resume information."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
code: str,
|
|
73
|
+
detail: str,
|
|
74
|
+
*,
|
|
75
|
+
render_id: str | None = None,
|
|
76
|
+
request_id: str | None = None,
|
|
77
|
+
idempotency_key: str | None = None,
|
|
78
|
+
status_code: int | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
super().__init__(detail)
|
|
81
|
+
self.code = code
|
|
82
|
+
self.detail = detail
|
|
83
|
+
self.render_id = render_id
|
|
84
|
+
self.request_id = request_id
|
|
85
|
+
self.idempotency_key = idempotency_key
|
|
86
|
+
self.status_code = status_code
|
medalbot_render/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: medalbot-render
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Async Python client for the Medalbot Render API
|
|
5
|
+
Project-URL: Homepage, https://github.com/Flo4604/render-sdks
|
|
6
|
+
Project-URL: Repository, https://github.com/Flo4604/render-sdks
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: httpx<1,>=0.28
|
|
11
|
+
Requires-Dist: pydantic<3,>=2.10
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: mypy<2,>=1.15; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest-asyncio<2,>=0.25; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest<10,>=8; extra == 'dev'
|
|
16
|
+
Requires-Dist: ruff<1,>=0.11; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# Python client
|
|
20
|
+
|
|
21
|
+
Await `client.render()` to get finished image URLs. The SDK handles cached results, retries, and polling.
|
|
22
|
+
|
|
23
|
+
Install the public wheel:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
python -m pip install https://github.com/Flo4604/render-sdks/releases/download/v0.1.1/medalbot_render-0.1.1-py3-none-any.whl
|
|
27
|
+
export RENDER_API_KEY="your-api-key"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import asyncio
|
|
32
|
+
import os
|
|
33
|
+
from medalbot_render import RenderClient
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def main():
|
|
37
|
+
async with RenderClient(api_key=os.environ["RENDER_API_KEY"]) as client:
|
|
38
|
+
images = await client.render(inspect_url="steam://...")
|
|
39
|
+
print(images["preview"])
|
|
40
|
+
print(images["front"])
|
|
41
|
+
print(images["back"])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
asyncio.run(main())
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Python 3.11 or newer is required. The default API URL is `https://render.medalbot.cc`. Set `base_url` to use another server. The client owns an HTTP connection pool. Use its async context manager or call `await client.aclose()`.
|
|
48
|
+
|
|
49
|
+
## Request options
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
images = await client.render(
|
|
53
|
+
inspect_url=inspect_url,
|
|
54
|
+
views=["front"],
|
|
55
|
+
format="webp",
|
|
56
|
+
timeout=120,
|
|
57
|
+
idempotency_key="listing-123-front-v1",
|
|
58
|
+
)
|
|
59
|
+
print(images["front"])
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
All three views are requested by default. WebP is lossless; PNG is also available. The result is a dictionary of URL strings, such as `{"front": "https://img.medalbot.cc/..."}`. Only requested views are present. Cache hits and fresh renders return the same shape.
|
|
63
|
+
|
|
64
|
+
The initial request sends `Prefer: wait=10`. If the render is still pending, the SDK polls at intervals from 500 milliseconds to two seconds. The default 120-second deadline covers submission, retry delays, and polling together. Transport failures and HTTP 429, 502, 503, and 504 receive at most four attempts. Retries keep the same idempotency key. Retry delays honor `Retry-After` up to ten seconds.
|
|
65
|
+
|
|
66
|
+
## Cancellation and resume
|
|
67
|
+
|
|
68
|
+
Cancel the asyncio task to stop a local wait. Cancellation raises `asyncio.CancelledError`; shared GPU work continues. A local deadline raises `RenderError` with code `wait_timeout`. Other errors include `code`, `detail`, `render_id`, `request_id`, `idempotency_key`, and `status_code` where available.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from medalbot_render import RenderError
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
images = await client.render(inspect_url=inspect_url, timeout=30)
|
|
75
|
+
except RenderError as error:
|
|
76
|
+
if error.code != "wait_timeout":
|
|
77
|
+
raise
|
|
78
|
+
if error.render_id:
|
|
79
|
+
images = await client.renders.wait(error.render_id)
|
|
80
|
+
else:
|
|
81
|
+
images = await client.render(
|
|
82
|
+
inspect_url=inspect_url, idempotency_key=error.idempotency_key
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`client.renders.create()` returns the admitted render without waiting for completion. `client.renders.get(id)` returns its current state. Those methods return `CompletedRender`, `PendingRender`, or `FailedRender` models. Completed renders contain `id` and the URL dictionary `images`. Pending renders contain `id` and `status`. Failed renders contain `id`, `status`, and `error`.
|
|
87
|
+
|
|
88
|
+
`client.renders.wait(id)` resumes a wait and returns the URL dictionary. Each method accepts a total `timeout` in seconds. Pass `views` to `wait` when you also want to check the original requested views. The SDK unwraps the HTTP response envelope `meta` and `data` for callers. Error request IDs come from `meta.requestId`.
|
|
89
|
+
|
|
90
|
+
## Development
|
|
91
|
+
|
|
92
|
+
Run these commands from the repository root:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
uv run --project packages/sdk-python --extra dev pytest packages/sdk-python/tests
|
|
96
|
+
uv run --project packages/sdk-python --extra dev mypy --strict packages/sdk-python/src
|
|
97
|
+
uv build --project packages/sdk-python
|
|
98
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
medalbot_render/__init__.py,sha256=-vr8e-eqNnOKZROIlPjIB07b1YBoqCCzko3nPAEmC4s,350
|
|
2
|
+
medalbot_render/client.py,sha256=RID_8WHww5VoThL8ZTaLXOPLLG1sGZh9u0m9a2XVmQk,11383
|
|
3
|
+
medalbot_render/models.py,sha256=TlFZ_rhtEn1QuJtX_pLAOgJRuxnme3EiS52KqnOepZ4,1893
|
|
4
|
+
medalbot_render/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
medalbot_render-0.1.1.dist-info/METADATA,sha256=ho6yV6dgL3SFLZlfcH3eUgzY6lrv7fzzmSjv2_g_ahY,4176
|
|
6
|
+
medalbot_render-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
medalbot_render-0.1.1.dist-info/licenses/LICENSE,sha256=YK3WELFQ2yA0AgKS-kX3XdPGPJkcbCl8axQNu1uHeQo,1070
|
|
8
|
+
medalbot_render-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Florian Eikel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|