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.
@@ -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
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.