renderly-ai 0.2.0__tar.gz
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.
- renderly_ai-0.2.0/LICENSE +21 -0
- renderly_ai-0.2.0/PKG-INFO +100 -0
- renderly_ai-0.2.0/README.md +76 -0
- renderly_ai-0.2.0/pyproject.toml +37 -0
- renderly_ai-0.2.0/renderly_ai/__init__.py +402 -0
- renderly_ai-0.2.0/renderly_ai/py.typed +0 -0
- renderly_ai-0.2.0/renderly_ai.egg-info/PKG-INFO +100 -0
- renderly_ai-0.2.0/renderly_ai.egg-info/SOURCES.txt +10 -0
- renderly_ai-0.2.0/renderly_ai.egg-info/dependency_links.txt +1 -0
- renderly_ai-0.2.0/renderly_ai.egg-info/top_level.txt +1 -0
- renderly_ai-0.2.0/setup.cfg +4 -0
- renderly_ai-0.2.0/tests/test_client.py +313 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Renderly
|
|
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.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: renderly-ai
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Official client for the Renderly AI API: search models, price, create and download image, video, audio and language results with one key and one credit wallet.
|
|
5
|
+
Author: Renderly
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://renderly-web-production.up.railway.app/
|
|
8
|
+
Project-URL: Documentation, https://renderly-web-production.up.railway.app/docs.html
|
|
9
|
+
Project-URL: API reference (OpenAPI), https://renderly-web-production.up.railway.app/openapi.json
|
|
10
|
+
Keywords: renderly,renderly-ai,ai,api,sdk,image-generation,video-generation,text-to-image,audio,llm
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# renderly-ai for Python
|
|
26
|
+
|
|
27
|
+
Official client for the [Renderly](https://renderly-web-production.up.railway.app/) API:
|
|
28
|
+
one key and one credit wallet (1 credit = $0.01) for image, video, audio, 3D and
|
|
29
|
+
language models. Standard library only; Python 3.8+. Keep your API key on a server.
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
pip install renderly-ai
|
|
33
|
+
export RENDERLY_API_KEY="rly_…" # Settings → API access
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from renderly_ai import Renderly
|
|
38
|
+
|
|
39
|
+
renderly = Renderly() # reads RENDERLY_API_KEY (and RENDERLY_BASE_URL if set)
|
|
40
|
+
|
|
41
|
+
result = renderly.run(
|
|
42
|
+
model_id="model-…", # from renderly.search_models("…")
|
|
43
|
+
operation="text-to-image",
|
|
44
|
+
input={"prompt": "A glass fox in morning light"},
|
|
45
|
+
max_credits=50, # refuse anything pricier, before any charge
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
for artifact in result["job"]["artifacts"]:
|
|
49
|
+
data = renderly.download_artifact(artifact["id"])
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`run()` quotes, checks `max_credits`, creates the job with a server-side wait of
|
|
53
|
+
up to 60 seconds and then long-polls until the job is `completed` or `refunded`.
|
|
54
|
+
|
|
55
|
+
## Step by step
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
models = renderly.search_models("portrait", kind="image", available=True)["models"]
|
|
59
|
+
model = renderly.model(models[0]["id"]) # creation fields + inputSchema
|
|
60
|
+
quote = renderly.quote(model_id=model["id"], operation=model["operation"], sku=model["sku"], input=input) # free
|
|
61
|
+
job = renderly.create_job(quote_id=quote["quoteId"], input=input, webhook_url="https://example.com/hooks/renderly", wait=30)["job"]
|
|
62
|
+
done = renderly.wait_for_job(job["id"], on_update=lambda j: print(j["status"]))
|
|
63
|
+
link = renderly.artifact(done["artifacts"][0]["id"]) # {"url", "expiresIn"}: 15 minutes
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
| Area | Methods |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| Models (public) | `models(...)`, `search_models(q, ...)`, `iter_models(...)`, `model(id)`, `catalog()`, `catalog_details(ids)`, `catalog_prices(ids)` |
|
|
69
|
+
| Account | `account()`, `wallet()` (incl. `reserved`), `ledger(...)`, `iter_ledger(...)`, `usage(from_=, to=, group_by=)` |
|
|
70
|
+
| Jobs | `quote()`, `create_job(..., wait=)`, `get_job(id, wait=)`, `list_jobs(status=, model_id=, from_=, to=, key_id=)`, `iter_jobs()`, `wait_for_job()`, `cancel_job(id)`, `run()` |
|
|
71
|
+
| Files | `upload(data, kind=)`, `upload_from_url(url, kind=)`, `artifact(id)`, `artifact_url(id)`, `download_artifact(id)` |
|
|
72
|
+
| Service | `status()` |
|
|
73
|
+
|
|
74
|
+
- **Inputs:** `file = renderly.upload(data, kind="image")` or
|
|
75
|
+
`renderly.upload_from_url("https://…/photo.png", kind="image")`, then pass
|
|
76
|
+
`{"inputAssetId": file["id"]}` in the model's input field.
|
|
77
|
+
- **Retries:** GETs and job creation retry network errors, 5xx and 429 (honouring
|
|
78
|
+
`Retry-After` up to 60 s); job creation reuses one request ID, so a retry never
|
|
79
|
+
starts a second job. Quotes retry at most once; uploads and cancellation retry
|
|
80
|
+
only after 429.
|
|
81
|
+
- **Rate limits:** `renderly.last_rate_limit` holds `limit`, `remaining`, `reset`
|
|
82
|
+
and `retry_after`; `renderly.last_request_id` the latest `X-Request-Id`.
|
|
83
|
+
- **Errors:** failures raise `RenderlyError` with `code`, `status`, `request_id`
|
|
84
|
+
and, for 429, `retry_after`.
|
|
85
|
+
|
|
86
|
+
## Webhooks
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from renderly_ai import verify_webhook
|
|
90
|
+
|
|
91
|
+
# Use the raw request body exactly as received.
|
|
92
|
+
event = verify_webhook(raw_body, headers["Renderly-Signature"], os.environ["RENDERLY_WEBHOOK_SECRET"])
|
|
93
|
+
if event["type"] == "job.completed":
|
|
94
|
+
artifacts = event["data"]["job"]["artifacts"]
|
|
95
|
+
elif event["type"] == "balance.low":
|
|
96
|
+
balance = event["data"]["balance"]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Full guide: <https://renderly-web-production.up.railway.app/docs.html> ·
|
|
100
|
+
OpenAPI: <https://renderly-web-production.up.railway.app/openapi.json>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# renderly-ai for Python
|
|
2
|
+
|
|
3
|
+
Official client for the [Renderly](https://renderly-web-production.up.railway.app/) API:
|
|
4
|
+
one key and one credit wallet (1 credit = $0.01) for image, video, audio, 3D and
|
|
5
|
+
language models. Standard library only; Python 3.8+. Keep your API key on a server.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install renderly-ai
|
|
9
|
+
export RENDERLY_API_KEY="rly_…" # Settings → API access
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from renderly_ai import Renderly
|
|
14
|
+
|
|
15
|
+
renderly = Renderly() # reads RENDERLY_API_KEY (and RENDERLY_BASE_URL if set)
|
|
16
|
+
|
|
17
|
+
result = renderly.run(
|
|
18
|
+
model_id="model-…", # from renderly.search_models("…")
|
|
19
|
+
operation="text-to-image",
|
|
20
|
+
input={"prompt": "A glass fox in morning light"},
|
|
21
|
+
max_credits=50, # refuse anything pricier, before any charge
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
for artifact in result["job"]["artifacts"]:
|
|
25
|
+
data = renderly.download_artifact(artifact["id"])
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`run()` quotes, checks `max_credits`, creates the job with a server-side wait of
|
|
29
|
+
up to 60 seconds and then long-polls until the job is `completed` or `refunded`.
|
|
30
|
+
|
|
31
|
+
## Step by step
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
models = renderly.search_models("portrait", kind="image", available=True)["models"]
|
|
35
|
+
model = renderly.model(models[0]["id"]) # creation fields + inputSchema
|
|
36
|
+
quote = renderly.quote(model_id=model["id"], operation=model["operation"], sku=model["sku"], input=input) # free
|
|
37
|
+
job = renderly.create_job(quote_id=quote["quoteId"], input=input, webhook_url="https://example.com/hooks/renderly", wait=30)["job"]
|
|
38
|
+
done = renderly.wait_for_job(job["id"], on_update=lambda j: print(j["status"]))
|
|
39
|
+
link = renderly.artifact(done["artifacts"][0]["id"]) # {"url", "expiresIn"}: 15 minutes
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
| Area | Methods |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| Models (public) | `models(...)`, `search_models(q, ...)`, `iter_models(...)`, `model(id)`, `catalog()`, `catalog_details(ids)`, `catalog_prices(ids)` |
|
|
45
|
+
| Account | `account()`, `wallet()` (incl. `reserved`), `ledger(...)`, `iter_ledger(...)`, `usage(from_=, to=, group_by=)` |
|
|
46
|
+
| Jobs | `quote()`, `create_job(..., wait=)`, `get_job(id, wait=)`, `list_jobs(status=, model_id=, from_=, to=, key_id=)`, `iter_jobs()`, `wait_for_job()`, `cancel_job(id)`, `run()` |
|
|
47
|
+
| Files | `upload(data, kind=)`, `upload_from_url(url, kind=)`, `artifact(id)`, `artifact_url(id)`, `download_artifact(id)` |
|
|
48
|
+
| Service | `status()` |
|
|
49
|
+
|
|
50
|
+
- **Inputs:** `file = renderly.upload(data, kind="image")` or
|
|
51
|
+
`renderly.upload_from_url("https://…/photo.png", kind="image")`, then pass
|
|
52
|
+
`{"inputAssetId": file["id"]}` in the model's input field.
|
|
53
|
+
- **Retries:** GETs and job creation retry network errors, 5xx and 429 (honouring
|
|
54
|
+
`Retry-After` up to 60 s); job creation reuses one request ID, so a retry never
|
|
55
|
+
starts a second job. Quotes retry at most once; uploads and cancellation retry
|
|
56
|
+
only after 429.
|
|
57
|
+
- **Rate limits:** `renderly.last_rate_limit` holds `limit`, `remaining`, `reset`
|
|
58
|
+
and `retry_after`; `renderly.last_request_id` the latest `X-Request-Id`.
|
|
59
|
+
- **Errors:** failures raise `RenderlyError` with `code`, `status`, `request_id`
|
|
60
|
+
and, for 429, `retry_after`.
|
|
61
|
+
|
|
62
|
+
## Webhooks
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from renderly_ai import verify_webhook
|
|
66
|
+
|
|
67
|
+
# Use the raw request body exactly as received.
|
|
68
|
+
event = verify_webhook(raw_body, headers["Renderly-Signature"], os.environ["RENDERLY_WEBHOOK_SECRET"])
|
|
69
|
+
if event["type"] == "job.completed":
|
|
70
|
+
artifacts = event["data"]["job"]["artifacts"]
|
|
71
|
+
elif event["type"] == "balance.low":
|
|
72
|
+
balance = event["data"]["balance"]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Full guide: <https://renderly-web-production.up.railway.app/docs.html> ·
|
|
76
|
+
OpenAPI: <https://renderly-web-production.up.railway.app/openapi.json>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "renderly-ai"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Official client for the Renderly AI API: search models, price, create and download image, video, audio and language results with one key and one credit wallet."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Renderly" }]
|
|
13
|
+
keywords = ["renderly", "renderly-ai", "ai", "api", "sdk", "image-generation", "video-generation", "text-to-image", "audio", "llm"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Topic :: Multimedia :: Graphics",
|
|
22
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = []
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://renderly-web-production.up.railway.app/"
|
|
29
|
+
Documentation = "https://renderly-web-production.up.railway.app/docs.html"
|
|
30
|
+
"API reference (OpenAPI)" = "https://renderly-web-production.up.railway.app/openapi.json"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools]
|
|
33
|
+
packages = ["renderly_ai"]
|
|
34
|
+
license-files = ["LICENSE"]
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.package-data]
|
|
37
|
+
renderly_ai = ["py.typed"]
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""Official client for the Renderly AI API.
|
|
2
|
+
|
|
3
|
+
Standard library only. Keep API keys on servers, never in browser code.
|
|
4
|
+
|
|
5
|
+
from renderly_ai import Renderly
|
|
6
|
+
client = Renderly() # reads RENDERLY_API_KEY (and RENDERLY_BASE_URL if set)
|
|
7
|
+
result = client.run(model_id="model-…", operation="text-to-image",
|
|
8
|
+
input={"prompt": "a glass fox"}, max_credits=50)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import urllib.request
|
|
21
|
+
from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Union
|
|
22
|
+
|
|
23
|
+
__all__ = ["Renderly", "RenderlyError", "new_request_id", "verify_webhook", "parse_rate_limit", "DEFAULT_BASE_URL", "TERMINAL_STATUSES"]
|
|
24
|
+
__version__ = "0.2.0"
|
|
25
|
+
|
|
26
|
+
DEFAULT_BASE_URL = "https://woovhhwoevjoipwltiwy.supabase.co/functions/v1/core-api"
|
|
27
|
+
TERMINAL_STATUSES = ("completed", "refunded")
|
|
28
|
+
TERMINAL = set(TERMINAL_STATUSES)
|
|
29
|
+
UPLOAD_KINDS = {"image", "video", "audio"}
|
|
30
|
+
MAX_RETRY_AFTER = 60.0
|
|
31
|
+
_REQUEST_ID = re.compile(r"^[A-Za-z0-9_-]{8,100}$")
|
|
32
|
+
# Retry policies: "all" (GETs and job creation, whose request ID makes a replay
|
|
33
|
+
# return the original job), "once" (quotes: at most one retry), "rate" (only
|
|
34
|
+
# 429, which the API answers before doing work: uploads, cancellation), None.
|
|
35
|
+
_RETRY = {"all": (True, True, None), "once": (True, True, 1), "rate": (False, False, None)}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RenderlyError(Exception):
|
|
39
|
+
"""An API, network or validation error with a stable ``code``.
|
|
40
|
+
|
|
41
|
+
``request_id`` is the X-Request-Id to quote to support; ``retry_after`` is
|
|
42
|
+
the Retry-After of a 429 in seconds; ``rate_limit`` holds the rate headers.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, message: str, code: str = "unknown_error", status: int = 0, request_id: Optional[str] = None,
|
|
46
|
+
retry_after: Optional[float] = None, rate_limit: Optional[Dict[str, Optional[float]]] = None) -> None:
|
|
47
|
+
super().__init__(message)
|
|
48
|
+
self.code = code
|
|
49
|
+
self.status = status
|
|
50
|
+
self.request_id = request_id
|
|
51
|
+
self.retry_after = retry_after
|
|
52
|
+
self.rate_limit = rate_limit
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _number(headers: Any, name: str) -> Optional[float]:
|
|
56
|
+
if headers is None:
|
|
57
|
+
return None
|
|
58
|
+
value = headers.get(name)
|
|
59
|
+
if value is None or value == "":
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
number = float(value)
|
|
63
|
+
except (TypeError, ValueError):
|
|
64
|
+
return None
|
|
65
|
+
return int(number) if number.is_integer() else number
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_rate_limit(headers: Any) -> Optional[Dict[str, Optional[float]]]:
|
|
69
|
+
"""Rate-limit headers as {limit, remaining, reset, retry_after}, or None when absent."""
|
|
70
|
+
info = {"limit": _number(headers, "X-RateLimit-Limit"), "remaining": _number(headers, "X-RateLimit-Remaining"),
|
|
71
|
+
"reset": _number(headers, "X-RateLimit-Reset"), "retry_after": _number(headers, "Retry-After")}
|
|
72
|
+
return info if any(value is not None for value in info.values()) else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def verify_webhook(payload, header: Optional[str], secret: str, tolerance: int = 300, now: Optional[float] = None) -> Dict[str, Any]:
|
|
76
|
+
"""Verify a webhook's Renderly-Signature header against the raw body; returns the event."""
|
|
77
|
+
import hashlib
|
|
78
|
+
import hmac
|
|
79
|
+
|
|
80
|
+
body = payload.decode("utf-8") if isinstance(payload, (bytes, bytearray)) else str(payload)
|
|
81
|
+
fields = dict(part.strip().split("=", 1) for part in (header or "").split(",") if "=" in part)
|
|
82
|
+
try:
|
|
83
|
+
timestamp = int(fields.get("t", ""))
|
|
84
|
+
except ValueError:
|
|
85
|
+
raise RenderlyError("Missing or malformed Renderly-Signature header.", "invalid_signature") from None
|
|
86
|
+
signature = fields.get("v1", "")
|
|
87
|
+
if not re.fullmatch(r"[a-f0-9]{64}", signature):
|
|
88
|
+
raise RenderlyError("Missing or malformed Renderly-Signature header.", "invalid_signature")
|
|
89
|
+
if abs((time.time() if now is None else now) - timestamp) > tolerance:
|
|
90
|
+
raise RenderlyError("The webhook timestamp is outside the allowed window.", "stale_signature")
|
|
91
|
+
expected = hmac.new(secret.encode("utf-8"), f"{timestamp}.{body}".encode("utf-8"), hashlib.sha256).hexdigest()
|
|
92
|
+
if not hmac.compare_digest(expected, signature):
|
|
93
|
+
raise RenderlyError("The webhook signature does not match.", "invalid_signature")
|
|
94
|
+
return json.loads(body)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def new_request_id() -> str:
|
|
98
|
+
"""A request ID valid for job creation: 8–100 letters, digits, "_" or "-"."""
|
|
99
|
+
return "req_" + uuid.uuid4().hex
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _wait_seconds(value: Optional[float]) -> Optional[int]:
|
|
103
|
+
if value is None or value is False:
|
|
104
|
+
return None
|
|
105
|
+
seconds = int(value)
|
|
106
|
+
if seconds < 0:
|
|
107
|
+
raise RenderlyError("wait must be between 0 and 60 seconds.", "invalid_request")
|
|
108
|
+
return min(seconds, 60) or None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _join(value: Union[None, str, Iterable[str]]) -> Optional[str]:
|
|
112
|
+
if value is None or isinstance(value, str):
|
|
113
|
+
return value
|
|
114
|
+
return ",".join(value)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _boolean(value: Optional[bool]) -> Optional[str]:
|
|
118
|
+
return None if value is None else ("true" if value else "false")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
Transport = Callable[[urllib.request.Request, float], Any]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _default_transport(request: urllib.request.Request, timeout: float):
|
|
125
|
+
return urllib.request.urlopen(request, timeout=timeout)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class Renderly:
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
api_key: Optional[str] = None,
|
|
132
|
+
base_url: Optional[str] = None,
|
|
133
|
+
timeout: float = 60.0,
|
|
134
|
+
max_retries: int = 2,
|
|
135
|
+
transport: Optional[Transport] = None,
|
|
136
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
137
|
+
) -> None:
|
|
138
|
+
self.api_key = api_key if api_key is not None else os.environ.get("RENDERLY_API_KEY")
|
|
139
|
+
self.base_url = (base_url or os.environ.get("RENDERLY_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
140
|
+
self.timeout = timeout
|
|
141
|
+
self.max_retries = max_retries
|
|
142
|
+
self._transport = transport or _default_transport
|
|
143
|
+
self._sleep = sleep
|
|
144
|
+
#: Rate-limit headers of the most recent response that carried them.
|
|
145
|
+
self.last_rate_limit: Optional[Dict[str, Optional[float]]] = None
|
|
146
|
+
#: X-Request-Id of the most recent API response.
|
|
147
|
+
self.last_request_id: Optional[str] = None
|
|
148
|
+
self.server_wait: Optional[bool] = None # False once the API rejected ?wait on GET /jobs/{id}
|
|
149
|
+
|
|
150
|
+
# -- transport -----------------------------------------------------------
|
|
151
|
+
def _request(self, method: str, path: str, *, body: Any = None, query: Optional[Dict[str, Any]] = None,
|
|
152
|
+
auth: bool = True, raw: bool = False, retry: Optional[str] = "default", timeout: Optional[float] = None) -> Any:
|
|
153
|
+
if auth and not self.api_key:
|
|
154
|
+
raise RenderlyError("Set api_key or the RENDERLY_API_KEY environment variable.", "auth_required", 401)
|
|
155
|
+
if retry == "default":
|
|
156
|
+
retry = "all" if method == "GET" else None
|
|
157
|
+
network, server, cap = _RETRY.get(retry, (False, False, 0)) if retry else (False, False, 0)
|
|
158
|
+
limit = self.max_retries if cap is None else min(self.max_retries, cap)
|
|
159
|
+
url = self.base_url + path
|
|
160
|
+
params = {k: v for k, v in (query or {}).items() if v is not None and v != ""}
|
|
161
|
+
if params:
|
|
162
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
163
|
+
headers = {"Accept": "application/json", "User-Agent": f"renderly-python/{__version__}"}
|
|
164
|
+
if auth:
|
|
165
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
166
|
+
data = None
|
|
167
|
+
if raw:
|
|
168
|
+
data, headers["Content-Type"] = body, "application/octet-stream"
|
|
169
|
+
elif body is not None:
|
|
170
|
+
data, headers["Content-Type"] = json.dumps(body).encode("utf-8"), "application/json"
|
|
171
|
+
attempt = 0
|
|
172
|
+
while True:
|
|
173
|
+
request = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
174
|
+
response_headers = None
|
|
175
|
+
try:
|
|
176
|
+
with self._transport(request, timeout or self.timeout) as response:
|
|
177
|
+
status, text = response.status, response.read().decode("utf-8")
|
|
178
|
+
response_headers = getattr(response, "headers", None)
|
|
179
|
+
except urllib.error.HTTPError as error:
|
|
180
|
+
status, text, response_headers = error.code, error.read().decode("utf-8", "replace"), error.headers
|
|
181
|
+
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
|
182
|
+
if network and attempt < limit:
|
|
183
|
+
self._sleep(0.5 * 2 ** attempt)
|
|
184
|
+
attempt += 1
|
|
185
|
+
continue
|
|
186
|
+
code = "timeout" if isinstance(error, TimeoutError) or "timed out" in str(error) else "network_error"
|
|
187
|
+
raise RenderlyError("The request timed out." if code == "timeout" else "The Renderly API could not be reached.", code) from error
|
|
188
|
+
request_id = response_headers.get("X-Request-Id") if response_headers is not None else None
|
|
189
|
+
rate_limit = parse_rate_limit(response_headers)
|
|
190
|
+
if request_id:
|
|
191
|
+
self.last_request_id = request_id
|
|
192
|
+
if rate_limit:
|
|
193
|
+
self.last_rate_limit = rate_limit
|
|
194
|
+
retry_after = rate_limit["retry_after"] if rate_limit else None
|
|
195
|
+
retryable = (bool(retry) and (retry_after is None or retry_after <= MAX_RETRY_AFTER)) if status == 429 else (status >= 500 and server)
|
|
196
|
+
if retryable and attempt < limit:
|
|
197
|
+
self._sleep(retry_after if retry_after is not None and retry_after >= 0 else 2 ** attempt)
|
|
198
|
+
attempt += 1
|
|
199
|
+
continue
|
|
200
|
+
try:
|
|
201
|
+
payload = json.loads(text) if text else None
|
|
202
|
+
except ValueError:
|
|
203
|
+
payload = None
|
|
204
|
+
if status >= 400:
|
|
205
|
+
error = (payload or {}).get("error") or {} if isinstance(payload, dict) else {}
|
|
206
|
+
raise RenderlyError(error.get("message") or f"Request failed with status {status}.", error.get("code") or "http_error", status,
|
|
207
|
+
request_id=request_id, retry_after=retry_after, rate_limit=rate_limit)
|
|
208
|
+
if payload is None:
|
|
209
|
+
raise RenderlyError("The Renderly API returned an unexpected response.", "invalid_response", status, request_id=request_id)
|
|
210
|
+
return payload
|
|
211
|
+
|
|
212
|
+
def _timeout_for(self, wait: Optional[int]) -> Optional[float]:
|
|
213
|
+
return max(self.timeout, wait + 15.0) if wait else None
|
|
214
|
+
|
|
215
|
+
# -- catalogue (public, no API key) --------------------------------------
|
|
216
|
+
def catalog(self) -> Dict[str, Any]:
|
|
217
|
+
"""Full public model catalogue."""
|
|
218
|
+
return self._request("GET", "/catalog", auth=False)
|
|
219
|
+
|
|
220
|
+
def catalog_details(self, ids: List[str]) -> Dict[str, Any]:
|
|
221
|
+
"""Full creation settings for up to 100 models."""
|
|
222
|
+
if not ids or len(ids) > 100:
|
|
223
|
+
raise RenderlyError("Pass between 1 and 100 model IDs.", "invalid_request")
|
|
224
|
+
return self._request("GET", "/catalog/details", auth=False, query={"ids": ",".join(ids)})
|
|
225
|
+
|
|
226
|
+
def catalog_prices(self, ids: List[str]) -> Dict[str, Any]:
|
|
227
|
+
"""Display prices for up to 12 models."""
|
|
228
|
+
if not ids or len(ids) > 12:
|
|
229
|
+
raise RenderlyError("Pass between 1 and 12 model IDs.", "invalid_request")
|
|
230
|
+
return self._request("GET", "/catalog/prices", auth=False, query={"ids": ",".join(ids)})
|
|
231
|
+
|
|
232
|
+
def models(self, *, q: Optional[str] = None, kind: Optional[str] = None, operation: Optional[str] = None, maker: Optional[str] = None,
|
|
233
|
+
available: Optional[bool] = None, limit: Optional[int] = None, cursor: Optional[str] = None) -> Dict[str, Any]:
|
|
234
|
+
"""One page of compact model entries: {models, nextCursor, total}."""
|
|
235
|
+
return self._request("GET", "/models", auth=False, query={"q": q, "kind": kind, "operation": operation, "maker": maker,
|
|
236
|
+
"available": _boolean(available), "limit": limit, "cursor": cursor})
|
|
237
|
+
|
|
238
|
+
def search_models(self, q: str, **filters: Any) -> Dict[str, Any]:
|
|
239
|
+
"""Search models by text; same filters as models()."""
|
|
240
|
+
return self.models(q=q, **filters)
|
|
241
|
+
|
|
242
|
+
def iter_models(self, **filters: Any) -> Iterator[Dict[str, Any]]:
|
|
243
|
+
"""Every matching model, following nextCursor."""
|
|
244
|
+
cursor = filters.pop("cursor", None)
|
|
245
|
+
while True:
|
|
246
|
+
page = self.models(cursor=cursor, **filters)
|
|
247
|
+
yield from page.get("models", [])
|
|
248
|
+
cursor = page.get("nextCursor")
|
|
249
|
+
if not cursor:
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
def model(self, model_id: str) -> Dict[str, Any]:
|
|
253
|
+
"""One model with full creation settings and inputSchema (JSON Schema of the quote input)."""
|
|
254
|
+
data = self._request("GET", "/models/" + urllib.parse.quote(model_id, safe=""), auth=False)
|
|
255
|
+
return data.get("model", data)
|
|
256
|
+
|
|
257
|
+
def status(self) -> Dict[str, Any]:
|
|
258
|
+
"""Public service status: components, daily outcomes and incidents."""
|
|
259
|
+
return self._request("GET", "/status", auth=False)
|
|
260
|
+
|
|
261
|
+
# -- account -------------------------------------------------------------
|
|
262
|
+
def account(self) -> Dict[str, Any]:
|
|
263
|
+
"""The account, its tier and limits, and the calling key."""
|
|
264
|
+
return self._request("GET", "/account")
|
|
265
|
+
|
|
266
|
+
def wallet(self) -> Dict[str, Any]:
|
|
267
|
+
"""Balance, reserved credits and lowBalanceCredits."""
|
|
268
|
+
return self._request("GET", "/wallet")
|
|
269
|
+
|
|
270
|
+
def ledger(self, *, limit: Optional[int] = None, before: Optional[str] = None, kind: Optional[str] = None,
|
|
271
|
+
from_: Optional[str] = None, to: Optional[str] = None) -> Dict[str, Any]:
|
|
272
|
+
"""A page of ledger entries, newest first: {entries, nextBefore}."""
|
|
273
|
+
return self._request("GET", "/ledger", query={"limit": limit, "before": before, "kind": kind, "from": from_, "to": to})
|
|
274
|
+
|
|
275
|
+
def iter_ledger(self, *, page_size: int = 200, **filters: Any) -> Iterator[Dict[str, Any]]:
|
|
276
|
+
before = filters.pop("before", None)
|
|
277
|
+
while True:
|
|
278
|
+
page = self.ledger(limit=page_size, before=before, **filters)
|
|
279
|
+
yield from page.get("entries", [])
|
|
280
|
+
before = page.get("nextBefore")
|
|
281
|
+
if not before:
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
def usage(self, *, from_: Optional[str] = None, to: Optional[str] = None, group_by: Optional[str] = None) -> Dict[str, Any]:
|
|
285
|
+
"""Usage totals and rows grouped by "day", "model" or "key"."""
|
|
286
|
+
return self._request("GET", "/usage", query={"from": from_, "to": to, "groupBy": group_by})
|
|
287
|
+
|
|
288
|
+
# -- creation ------------------------------------------------------------
|
|
289
|
+
def quote(self, *, model_id: str, operation: str, input: Dict[str, Any], sku: str = "selected-settings-v1") -> Dict[str, Any]:
|
|
290
|
+
"""Price a request without charging. Returns quoteId, credits, maxCredits and expiresAt."""
|
|
291
|
+
return self._request("POST", "/quotes", body={"modelId": model_id, "operation": operation, "sku": sku, "input": input}, retry="once")
|
|
292
|
+
|
|
293
|
+
def create_job(self, *, quote_id: str, input: Dict[str, Any], request_id: Optional[str] = None, webhook_url: Optional[str] = None,
|
|
294
|
+
wait: Optional[float] = None) -> Dict[str, Any]:
|
|
295
|
+
"""Accept a quote and start the job. Retrying with the same request_id returns the original job.
|
|
296
|
+
|
|
297
|
+
``wait`` (0–60 s) asks the API to hold the response until the job finishes.
|
|
298
|
+
"""
|
|
299
|
+
request_id = request_id or new_request_id()
|
|
300
|
+
if not _REQUEST_ID.match(request_id):
|
|
301
|
+
raise RenderlyError('request_id must be 8–100 letters, digits, "_" or "-".', "invalid_request")
|
|
302
|
+
seconds = _wait_seconds(wait)
|
|
303
|
+
body = {"quoteId": quote_id, "requestId": request_id, "input": input}
|
|
304
|
+
if webhook_url:
|
|
305
|
+
body["webhookUrl"] = webhook_url
|
|
306
|
+
return self._request("POST", "/jobs", body=body, query={"wait": seconds}, retry="all", timeout=self._timeout_for(seconds))
|
|
307
|
+
|
|
308
|
+
def get_job(self, job_id: str, *, wait: Optional[float] = None) -> Dict[str, Any]:
|
|
309
|
+
"""One job. With ``wait`` (0–60 s) the API answers as soon as it finishes or the wait ends."""
|
|
310
|
+
seconds = _wait_seconds(wait)
|
|
311
|
+
return self._request("GET", "/jobs/" + urllib.parse.quote(job_id, safe=""), query={"wait": seconds}, timeout=self._timeout_for(seconds))["job"]
|
|
312
|
+
|
|
313
|
+
def list_jobs(self, *, limit: Optional[int] = None, before: Optional[str] = None, status: Union[None, str, Iterable[str]] = None,
|
|
314
|
+
model_id: Optional[str] = None, from_: Optional[str] = None, to: Optional[str] = None, key_id: Optional[str] = None) -> Dict[str, Any]:
|
|
315
|
+
"""A page of owned jobs, newest first. Pass the returned nextBefore to continue."""
|
|
316
|
+
return self._request("GET", "/jobs", query={"limit": limit, "before": before, "status": _join(status), "modelId": model_id,
|
|
317
|
+
"from": from_, "to": to, "keyId": key_id})
|
|
318
|
+
|
|
319
|
+
def iter_jobs(self, *, page_size: int = 50, **filters: Any) -> Iterator[Dict[str, Any]]:
|
|
320
|
+
before = filters.pop("before", None)
|
|
321
|
+
while True:
|
|
322
|
+
page = self.list_jobs(limit=page_size, before=before, **filters)
|
|
323
|
+
yield from page.get("jobs", [])
|
|
324
|
+
before = page.get("nextBefore")
|
|
325
|
+
if not before:
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
def cancel_job(self, job_id: str) -> Dict[str, Any]:
|
|
329
|
+
"""Cancel a job not yet sent for generation; returns {job, balance, cancelled}."""
|
|
330
|
+
return self._request("POST", "/jobs/" + urllib.parse.quote(job_id, safe="") + "/cancel", body={}, retry="rate")
|
|
331
|
+
|
|
332
|
+
def wait_for_job(self, job_id: str, *, interval: float = 3.0, timeout: float = 900.0, long_poll: bool = True,
|
|
333
|
+
on_update: Optional[Callable[[Dict[str, Any]], None]] = None) -> Dict[str, Any]:
|
|
334
|
+
"""Wait until the job is completed or refunded, long-polling when the API supports it."""
|
|
335
|
+
deadline = time.monotonic() + timeout
|
|
336
|
+
while True:
|
|
337
|
+
remaining, started, job = int(deadline - time.monotonic()), time.monotonic(), None
|
|
338
|
+
if long_poll and self.server_wait is not False and remaining >= 1:
|
|
339
|
+
try:
|
|
340
|
+
job = self.get_job(job_id, wait=min(60, remaining))
|
|
341
|
+
self.server_wait = True
|
|
342
|
+
except RenderlyError as error:
|
|
343
|
+
if error.status != 400 or self.server_wait is not None:
|
|
344
|
+
raise
|
|
345
|
+
self.server_wait = False
|
|
346
|
+
if job is None:
|
|
347
|
+
job = self.get_job(job_id)
|
|
348
|
+
if on_update:
|
|
349
|
+
on_update(job)
|
|
350
|
+
if job.get("status") in TERMINAL:
|
|
351
|
+
return job
|
|
352
|
+
if time.monotonic() + (0 if self.server_wait else interval) > deadline:
|
|
353
|
+
raise RenderlyError("The job did not finish before the wait timeout. It may still complete; check it again later.", "wait_timeout")
|
|
354
|
+
if not self.server_wait or time.monotonic() - started < 1:
|
|
355
|
+
self._sleep(interval)
|
|
356
|
+
|
|
357
|
+
# -- files ---------------------------------------------------------------
|
|
358
|
+
def artifact(self, artifact_id: str) -> Dict[str, Any]:
|
|
359
|
+
"""{url, expiresIn}: a signed download link for a completed artifact (15 minutes)."""
|
|
360
|
+
return self._request("GET", "/artifacts/" + urllib.parse.quote(artifact_id, safe=""))
|
|
361
|
+
|
|
362
|
+
def artifact_url(self, artifact_id: str) -> str:
|
|
363
|
+
return self.artifact(artifact_id)["url"]
|
|
364
|
+
|
|
365
|
+
def download_artifact(self, artifact_id: str) -> bytes:
|
|
366
|
+
"""Download an artifact's bytes. The API key is never sent to storage."""
|
|
367
|
+
url = self.artifact_url(artifact_id)
|
|
368
|
+
try:
|
|
369
|
+
with self._transport(urllib.request.Request(url, method="GET"), self.timeout) as response:
|
|
370
|
+
return response.read()
|
|
371
|
+
except urllib.error.HTTPError as error:
|
|
372
|
+
raise RenderlyError(f"Artifact download failed with status {error.code}.", "download_failed", error.code) from error
|
|
373
|
+
|
|
374
|
+
def upload(self, data: bytes, *, kind: str) -> Dict[str, Any]:
|
|
375
|
+
"""Upload an image, MP4 video or audio input; use {"inputAssetId": result["id"]} in the model input."""
|
|
376
|
+
if kind not in UPLOAD_KINDS:
|
|
377
|
+
raise RenderlyError('kind must be "image", "video" or "audio".', "invalid_request")
|
|
378
|
+
return self._request("POST", "/uploads", query={"kind": kind}, body=data, raw=True, retry="rate")
|
|
379
|
+
|
|
380
|
+
def upload_from_url(self, url: str, *, kind: str) -> Dict[str, Any]:
|
|
381
|
+
"""Import a public HTTPS file as an input (same checks and 24 MiB limit as upload)."""
|
|
382
|
+
if kind not in UPLOAD_KINDS:
|
|
383
|
+
raise RenderlyError('kind must be "image", "video" or "audio".', "invalid_request")
|
|
384
|
+
if not str(url).lower().startswith("https://"):
|
|
385
|
+
raise RenderlyError("url must be a public HTTPS address.", "invalid_upload_url")
|
|
386
|
+
return self._request("POST", "/uploads", query={"kind": kind}, body={"url": str(url)}, retry="rate")
|
|
387
|
+
|
|
388
|
+
def run(self, *, model_id: str, operation: str, input: Dict[str, Any], sku: str = "selected-settings-v1",
|
|
389
|
+
max_credits: Optional[float] = None, request_id: Optional[str] = None, webhook_url: Optional[str] = None,
|
|
390
|
+
server_wait: float = 60, interval: float = 3.0, timeout: float = 900.0,
|
|
391
|
+
on_update: Optional[Callable[[Dict[str, Any]], None]] = None) -> Dict[str, Any]:
|
|
392
|
+
"""Quote, create and wait in one call. max_credits refuses an over-budget quote before any charge."""
|
|
393
|
+
quote = self.quote(model_id=model_id, operation=operation, input=input, sku=sku)
|
|
394
|
+
ceiling = quote.get("maxCredits", quote.get("credits"))
|
|
395
|
+
if max_credits is not None and float(ceiling) > max_credits:
|
|
396
|
+
raise RenderlyError(f"The quote ({ceiling} credits) is above max_credits ({max_credits}). Nothing was charged.", "over_budget")
|
|
397
|
+
created = self.create_job(quote_id=quote["quoteId"], input=input, request_id=request_id, webhook_url=webhook_url, wait=server_wait or None)
|
|
398
|
+
if on_update:
|
|
399
|
+
on_update(created["job"])
|
|
400
|
+
job = created["job"] if created["job"].get("status") in TERMINAL else self.wait_for_job(
|
|
401
|
+
created["job"]["id"], interval=interval, timeout=timeout, on_update=on_update)
|
|
402
|
+
return {"quote": quote, "job": job, "balance": created.get("balance")}
|
|
File without changes
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: renderly-ai
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Official client for the Renderly AI API: search models, price, create and download image, video, audio and language results with one key and one credit wallet.
|
|
5
|
+
Author: Renderly
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://renderly-web-production.up.railway.app/
|
|
8
|
+
Project-URL: Documentation, https://renderly-web-production.up.railway.app/docs.html
|
|
9
|
+
Project-URL: API reference (OpenAPI), https://renderly-web-production.up.railway.app/openapi.json
|
|
10
|
+
Keywords: renderly,renderly-ai,ai,api,sdk,image-generation,video-generation,text-to-image,audio,llm
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# renderly-ai for Python
|
|
26
|
+
|
|
27
|
+
Official client for the [Renderly](https://renderly-web-production.up.railway.app/) API:
|
|
28
|
+
one key and one credit wallet (1 credit = $0.01) for image, video, audio, 3D and
|
|
29
|
+
language models. Standard library only; Python 3.8+. Keep your API key on a server.
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
pip install renderly-ai
|
|
33
|
+
export RENDERLY_API_KEY="rly_…" # Settings → API access
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from renderly_ai import Renderly
|
|
38
|
+
|
|
39
|
+
renderly = Renderly() # reads RENDERLY_API_KEY (and RENDERLY_BASE_URL if set)
|
|
40
|
+
|
|
41
|
+
result = renderly.run(
|
|
42
|
+
model_id="model-…", # from renderly.search_models("…")
|
|
43
|
+
operation="text-to-image",
|
|
44
|
+
input={"prompt": "A glass fox in morning light"},
|
|
45
|
+
max_credits=50, # refuse anything pricier, before any charge
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
for artifact in result["job"]["artifacts"]:
|
|
49
|
+
data = renderly.download_artifact(artifact["id"])
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`run()` quotes, checks `max_credits`, creates the job with a server-side wait of
|
|
53
|
+
up to 60 seconds and then long-polls until the job is `completed` or `refunded`.
|
|
54
|
+
|
|
55
|
+
## Step by step
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
models = renderly.search_models("portrait", kind="image", available=True)["models"]
|
|
59
|
+
model = renderly.model(models[0]["id"]) # creation fields + inputSchema
|
|
60
|
+
quote = renderly.quote(model_id=model["id"], operation=model["operation"], sku=model["sku"], input=input) # free
|
|
61
|
+
job = renderly.create_job(quote_id=quote["quoteId"], input=input, webhook_url="https://example.com/hooks/renderly", wait=30)["job"]
|
|
62
|
+
done = renderly.wait_for_job(job["id"], on_update=lambda j: print(j["status"]))
|
|
63
|
+
link = renderly.artifact(done["artifacts"][0]["id"]) # {"url", "expiresIn"}: 15 minutes
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
| Area | Methods |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| Models (public) | `models(...)`, `search_models(q, ...)`, `iter_models(...)`, `model(id)`, `catalog()`, `catalog_details(ids)`, `catalog_prices(ids)` |
|
|
69
|
+
| Account | `account()`, `wallet()` (incl. `reserved`), `ledger(...)`, `iter_ledger(...)`, `usage(from_=, to=, group_by=)` |
|
|
70
|
+
| Jobs | `quote()`, `create_job(..., wait=)`, `get_job(id, wait=)`, `list_jobs(status=, model_id=, from_=, to=, key_id=)`, `iter_jobs()`, `wait_for_job()`, `cancel_job(id)`, `run()` |
|
|
71
|
+
| Files | `upload(data, kind=)`, `upload_from_url(url, kind=)`, `artifact(id)`, `artifact_url(id)`, `download_artifact(id)` |
|
|
72
|
+
| Service | `status()` |
|
|
73
|
+
|
|
74
|
+
- **Inputs:** `file = renderly.upload(data, kind="image")` or
|
|
75
|
+
`renderly.upload_from_url("https://…/photo.png", kind="image")`, then pass
|
|
76
|
+
`{"inputAssetId": file["id"]}` in the model's input field.
|
|
77
|
+
- **Retries:** GETs and job creation retry network errors, 5xx and 429 (honouring
|
|
78
|
+
`Retry-After` up to 60 s); job creation reuses one request ID, so a retry never
|
|
79
|
+
starts a second job. Quotes retry at most once; uploads and cancellation retry
|
|
80
|
+
only after 429.
|
|
81
|
+
- **Rate limits:** `renderly.last_rate_limit` holds `limit`, `remaining`, `reset`
|
|
82
|
+
and `retry_after`; `renderly.last_request_id` the latest `X-Request-Id`.
|
|
83
|
+
- **Errors:** failures raise `RenderlyError` with `code`, `status`, `request_id`
|
|
84
|
+
and, for 429, `retry_after`.
|
|
85
|
+
|
|
86
|
+
## Webhooks
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from renderly_ai import verify_webhook
|
|
90
|
+
|
|
91
|
+
# Use the raw request body exactly as received.
|
|
92
|
+
event = verify_webhook(raw_body, headers["Renderly-Signature"], os.environ["RENDERLY_WEBHOOK_SECRET"])
|
|
93
|
+
if event["type"] == "job.completed":
|
|
94
|
+
artifacts = event["data"]["job"]["artifacts"]
|
|
95
|
+
elif event["type"] == "balance.low":
|
|
96
|
+
balance = event["data"]["balance"]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Full guide: <https://renderly-web-production.up.railway.app/docs.html> ·
|
|
100
|
+
OpenAPI: <https://renderly-web-production.up.railway.app/openapi.json>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
renderly_ai
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import unittest
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.parse
|
|
7
|
+
from email.message import Message
|
|
8
|
+
|
|
9
|
+
from renderly_ai import Renderly, RenderlyError, new_request_id, verify_webhook, parse_rate_limit, DEFAULT_BASE_URL
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def headers_of(values):
|
|
13
|
+
message = Message()
|
|
14
|
+
for key, value in (values or {}).items():
|
|
15
|
+
message[key] = value
|
|
16
|
+
return message
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FakeResponse:
|
|
20
|
+
def __init__(self, status, body, headers=None):
|
|
21
|
+
self.status = status
|
|
22
|
+
self._body = body if isinstance(body, bytes) else json.dumps(body).encode()
|
|
23
|
+
self.headers = headers_of(headers)
|
|
24
|
+
|
|
25
|
+
def read(self):
|
|
26
|
+
return self._body
|
|
27
|
+
|
|
28
|
+
def __enter__(self):
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
def __exit__(self, *exc):
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def http_error(url, status, body, headers=None):
|
|
36
|
+
return urllib.error.HTTPError(url, status, "error", headers_of(headers), io.BytesIO(json.dumps(body).encode()))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Transport:
|
|
40
|
+
def __init__(self, handler):
|
|
41
|
+
self.handler, self.calls = handler, []
|
|
42
|
+
|
|
43
|
+
def __call__(self, request, timeout):
|
|
44
|
+
self.calls.append(request)
|
|
45
|
+
result = self.handler(request, len(self.calls))
|
|
46
|
+
if isinstance(result, Exception):
|
|
47
|
+
raise result
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def client(handler, **kwargs):
|
|
52
|
+
transport = Transport(handler)
|
|
53
|
+
return Renderly(api_key=kwargs.pop("api_key", "rk_test"), transport=transport, sleep=lambda s: None, **kwargs), transport
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def body(request):
|
|
57
|
+
return json.loads(request.data.decode())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def path_of(request):
|
|
61
|
+
return urllib.parse.urlsplit(request.full_url).path.split("/core-api", 1)[-1]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def query_of(request):
|
|
65
|
+
return dict(urllib.parse.parse_qsl(urllib.parse.urlsplit(request.full_url).query))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ClientTests(unittest.TestCase):
|
|
69
|
+
def test_bearer_key_on_account_calls_only(self):
|
|
70
|
+
c, t = client(lambda r, n: FakeResponse(200, {"balance": 125} if r.full_url.endswith("/wallet") else {"models": []}))
|
|
71
|
+
self.assertEqual(c.wallet(), {"balance": 125})
|
|
72
|
+
c.catalog()
|
|
73
|
+
self.assertEqual(t.calls[0].full_url, DEFAULT_BASE_URL + "/wallet")
|
|
74
|
+
self.assertEqual(t.calls[0].get_header("Authorization"), "Bearer rk_test")
|
|
75
|
+
self.assertIsNone(t.calls[1].get_header("Authorization"))
|
|
76
|
+
|
|
77
|
+
def test_missing_key_fails_before_request(self):
|
|
78
|
+
c, t = client(lambda r, n: FakeResponse(200, {}), api_key="")
|
|
79
|
+
with self.assertRaises(RenderlyError) as caught:
|
|
80
|
+
c.wallet()
|
|
81
|
+
self.assertEqual(caught.exception.code, "auth_required")
|
|
82
|
+
self.assertEqual(t.calls, [])
|
|
83
|
+
|
|
84
|
+
def test_api_error_fields(self):
|
|
85
|
+
c, _ = client(lambda r, n: http_error(r.full_url, 409, {"error": {"code": "insufficient_balance", "message": "Your credit balance is too low."}}))
|
|
86
|
+
with self.assertRaises(RenderlyError) as caught:
|
|
87
|
+
c.create_job(quote_id="q", input={})
|
|
88
|
+
self.assertEqual((caught.exception.code, caught.exception.status), ("insufficient_balance", 409))
|
|
89
|
+
|
|
90
|
+
def test_job_retry_keeps_request_id(self):
|
|
91
|
+
def handler(r, n):
|
|
92
|
+
if n == 1:
|
|
93
|
+
return http_error(r.full_url, 503, {"error": {"code": "service_unavailable", "message": "x"}}, {"Retry-After": "0"})
|
|
94
|
+
return FakeResponse(200, {"job": {"id": "job-1", "status": "queued"}, "balance": 90, "reused": True})
|
|
95
|
+
c, t = client(handler, max_retries=1)
|
|
96
|
+
self.assertTrue(c.create_job(quote_id="quote-1", input={"prompt": "a fox"})["reused"])
|
|
97
|
+
self.assertEqual(len(t.calls), 2)
|
|
98
|
+
self.assertEqual(body(t.calls[0])["requestId"], body(t.calls[1])["requestId"])
|
|
99
|
+
|
|
100
|
+
def test_upload_not_retried_after_server_error_and_kind_validated(self):
|
|
101
|
+
c, t = client(lambda r, n: http_error(r.full_url, 503, {"error": {"code": "service_unavailable", "message": "x"}}), max_retries=3)
|
|
102
|
+
with self.assertRaises(RenderlyError):
|
|
103
|
+
c.upload(b"\x01\x02", kind="image")
|
|
104
|
+
self.assertEqual(len(t.calls), 1)
|
|
105
|
+
self.assertEqual(t.calls[0].full_url, DEFAULT_BASE_URL + "/uploads?kind=image")
|
|
106
|
+
with self.assertRaises(RenderlyError):
|
|
107
|
+
c.upload(b"\x01", kind="pdf")
|
|
108
|
+
|
|
109
|
+
def test_wait_for_job_until_terminal(self):
|
|
110
|
+
statuses = ["queued", "processing", "completed"]
|
|
111
|
+
c, _ = client(lambda r, n: FakeResponse(200, {"job": {"id": "job-1", "status": statuses[n - 1]}}))
|
|
112
|
+
seen = []
|
|
113
|
+
self.assertEqual(c.wait_for_job("job-1", interval=0, on_update=lambda j: seen.append(j["status"]))["status"], "completed")
|
|
114
|
+
self.assertEqual(seen, statuses)
|
|
115
|
+
|
|
116
|
+
def test_run_refuses_over_budget_quote(self):
|
|
117
|
+
c, t = client(lambda r, n: FakeResponse(200, {"quoteId": "quote-1", "credits": 40, "maxCredits": 55}))
|
|
118
|
+
with self.assertRaises(RenderlyError) as caught:
|
|
119
|
+
c.run(model_id="m", operation="text-to-image", input={"prompt": "x"}, max_credits=50)
|
|
120
|
+
self.assertEqual(caught.exception.code, "over_budget")
|
|
121
|
+
self.assertEqual(len(t.calls), 1)
|
|
122
|
+
|
|
123
|
+
def test_run_end_to_end(self):
|
|
124
|
+
def handler(r, n):
|
|
125
|
+
if r.full_url.endswith("/quotes"):
|
|
126
|
+
return FakeResponse(200, {"quoteId": "quote-1", "credits": 12})
|
|
127
|
+
if path_of(r) == "/jobs":
|
|
128
|
+
return FakeResponse(200, {"job": {"id": "job-1", "status": "queued"}, "balance": 88})
|
|
129
|
+
return FakeResponse(200, {"job": {"id": "job-1", "status": "completed", "artifacts": [{"id": "art-1"}]}})
|
|
130
|
+
c, t = client(handler)
|
|
131
|
+
result = c.run(model_id="m", operation="text-to-image", input={"prompt": "x"}, max_credits=20, interval=0)
|
|
132
|
+
self.assertEqual((result["job"]["status"], result["balance"]), ("completed", 88))
|
|
133
|
+
self.assertEqual(body(t.calls[0]), {"modelId": "m", "operation": "text-to-image", "sku": "selected-settings-v1", "input": {"prompt": "x"}})
|
|
134
|
+
self.assertEqual(query_of(t.calls[1]), {"wait": "60"})
|
|
135
|
+
|
|
136
|
+
def test_iter_jobs_pages(self):
|
|
137
|
+
pages = [{"jobs": [{"id": "a"}, {"id": "b"}], "nextBefore": "2026-10-01T00:00:00Z"}, {"jobs": [{"id": "c"}], "nextBefore": None}]
|
|
138
|
+
c, t = client(lambda r, n: FakeResponse(200, pages[n - 1]))
|
|
139
|
+
self.assertEqual([j["id"] for j in c.iter_jobs(page_size=2)], ["a", "b", "c"])
|
|
140
|
+
self.assertIn("before=2026-10-01T00%3A00%3A00Z", t.calls[1].full_url)
|
|
141
|
+
|
|
142
|
+
def test_download_does_not_send_key_to_storage(self):
|
|
143
|
+
c, t = client(lambda r, n: FakeResponse(200, {"url": "https://storage.example/f.png?sig=1"}) if "/artifacts/" in r.full_url else FakeResponse(200, b"\x07\x08"))
|
|
144
|
+
self.assertEqual(c.download_artifact("art-1"), b"\x07\x08")
|
|
145
|
+
self.assertIsNone(t.calls[1].get_header("Authorization"))
|
|
146
|
+
|
|
147
|
+
def test_request_id_format(self):
|
|
148
|
+
import re
|
|
149
|
+
for _ in range(20):
|
|
150
|
+
self.assertRegex(new_request_id(), re.compile(r"^[A-Za-z0-9_-]{8,100}$"))
|
|
151
|
+
|
|
152
|
+
def test_verify_webhook_matches_server_signature(self):
|
|
153
|
+
secret = "whsec_" + "c" * 64
|
|
154
|
+
body = '{"id":"evt_9","type":"job.completed","createdAt":"2026-10-23T08:01:01Z","attempt":1,"data":{"job":{"id":"job-9","status":"completed"}}}'
|
|
155
|
+
header = "t=1800000000,v1=f0030f62fd11da782b19cfe354c64bcc8934a29b80a22e816bd0f06c427abc09"
|
|
156
|
+
self.assertEqual(verify_webhook(body.encode(), header, secret, now=1800000010)["data"]["job"]["id"], "job-9")
|
|
157
|
+
for bad_body, bad_header, now, code in [(body + " ", header, 1800000010, "invalid_signature"), (body, header, 1800000400, "stale_signature"), (body, "t=1,v1=zz", 1, "invalid_signature")]:
|
|
158
|
+
with self.assertRaises(RenderlyError) as caught:
|
|
159
|
+
verify_webhook(bad_body, bad_header, secret, now=now)
|
|
160
|
+
self.assertEqual(caught.exception.code, code)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_base_url_env_override(self):
|
|
164
|
+
saved = os.environ.get("RENDERLY_BASE_URL")
|
|
165
|
+
os.environ["RENDERLY_BASE_URL"] = "https://api.example.test/v1/"
|
|
166
|
+
try:
|
|
167
|
+
c, t = client(lambda r, n: FakeResponse(200, {"balance": 1}))
|
|
168
|
+
c.wallet()
|
|
169
|
+
self.assertEqual(t.calls[0].full_url, "https://api.example.test/v1/wallet")
|
|
170
|
+
finally:
|
|
171
|
+
if saved is None:
|
|
172
|
+
del os.environ["RENDERLY_BASE_URL"]
|
|
173
|
+
else:
|
|
174
|
+
os.environ["RENDERLY_BASE_URL"] = saved
|
|
175
|
+
|
|
176
|
+
def test_rate_limit_and_request_id_exposed(self):
|
|
177
|
+
headers = {"X-Request-Id": "req_1", "X-RateLimit-Limit": "600", "X-RateLimit-Remaining": "599", "X-RateLimit-Reset": "1800000060"}
|
|
178
|
+
c, _ = client(lambda r, n: FakeResponse(200, {"balance": 5}, headers))
|
|
179
|
+
c.wallet()
|
|
180
|
+
self.assertEqual(c.last_rate_limit, {"limit": 600, "remaining": 599, "reset": 1800000060, "retry_after": None})
|
|
181
|
+
self.assertEqual(c.last_request_id, "req_1")
|
|
182
|
+
self.assertIsNone(parse_rate_limit(headers_of({})))
|
|
183
|
+
|
|
184
|
+
def test_error_carries_request_id_and_retry_after(self):
|
|
185
|
+
def handler(r, n):
|
|
186
|
+
return http_error(r.full_url, 429, {"error": {"code": "rate_limited", "message": "Wait."}}, {"Retry-After": "120", "X-Request-Id": "req_2"})
|
|
187
|
+
c, t = client(handler)
|
|
188
|
+
with self.assertRaises(RenderlyError) as caught:
|
|
189
|
+
c.wallet()
|
|
190
|
+
self.assertEqual((caught.exception.code, caught.exception.retry_after, caught.exception.request_id), ("rate_limited", 120, "req_2"))
|
|
191
|
+
self.assertEqual(len(t.calls), 1) # Retry-After above 60 s is reported, not slept through
|
|
192
|
+
|
|
193
|
+
def test_429_retried_for_gets_and_job_creation(self):
|
|
194
|
+
slept = []
|
|
195
|
+
|
|
196
|
+
def handler(r, n):
|
|
197
|
+
if n % 2:
|
|
198
|
+
return http_error(r.full_url, 429, {"error": {"code": "rate_limited", "message": "x"}}, {"Retry-After": "2"})
|
|
199
|
+
return FakeResponse(200, {"jobs": [], "nextBefore": None} if r.get_method() == "GET" else {"job": {"id": "j", "status": "queued"}, "balance": 1})
|
|
200
|
+
transport = Transport(handler)
|
|
201
|
+
c = Renderly(api_key="k", transport=transport, sleep=slept.append)
|
|
202
|
+
c.list_jobs()
|
|
203
|
+
c.create_job(quote_id="q", input={})
|
|
204
|
+
self.assertEqual(len(transport.calls), 4)
|
|
205
|
+
self.assertEqual(slept, [2, 2])
|
|
206
|
+
|
|
207
|
+
def test_quotes_retried_at_most_once(self):
|
|
208
|
+
c, t = client(lambda r, n: http_error(r.full_url, 503, {"error": {"code": "service_unavailable", "message": "x"}}, {"Retry-After": "0"}), max_retries=5)
|
|
209
|
+
with self.assertRaises(RenderlyError):
|
|
210
|
+
c.quote(model_id="m", operation="o", input={})
|
|
211
|
+
self.assertEqual(len(t.calls), 2)
|
|
212
|
+
|
|
213
|
+
def test_upload_retried_after_429(self):
|
|
214
|
+
def handler(r, n):
|
|
215
|
+
if n == 1:
|
|
216
|
+
return http_error(r.full_url, 429, {"error": {"code": "rate_limited", "message": "x"}}, {"Retry-After": "0"})
|
|
217
|
+
return FakeResponse(200, {"id": "up-1"})
|
|
218
|
+
c, t = client(handler)
|
|
219
|
+
self.assertEqual(c.upload(b"\x01", kind="image")["id"], "up-1")
|
|
220
|
+
self.assertEqual(len(t.calls), 2)
|
|
221
|
+
|
|
222
|
+
def test_upload_from_url(self):
|
|
223
|
+
c, t = client(lambda r, n: FakeResponse(200, {"id": "up-2", "kind": "video"}))
|
|
224
|
+
self.assertEqual(c.upload_from_url("https://cdn.example.com/clip.mp4", kind="video")["id"], "up-2")
|
|
225
|
+
self.assertEqual(body(t.calls[0]), {"url": "https://cdn.example.com/clip.mp4"})
|
|
226
|
+
self.assertEqual(query_of(t.calls[0]), {"kind": "video"})
|
|
227
|
+
self.assertEqual(t.calls[0].get_header("Content-type"), "application/json")
|
|
228
|
+
with self.assertRaises(RenderlyError) as caught:
|
|
229
|
+
c.upload_from_url("http://cdn.example.com/a.png", kind="image")
|
|
230
|
+
self.assertEqual(caught.exception.code, "invalid_upload_url")
|
|
231
|
+
|
|
232
|
+
def test_account_usage_status(self):
|
|
233
|
+
routes = {"/account": {"account": {"tier": "scale"}, "key": None}, "/usage": {"groupBy": "key", "rows": []}, "/status": {"components": [], "daily": [], "incidents": []}}
|
|
234
|
+
c, t = client(lambda r, n: FakeResponse(200, routes[path_of(r)]))
|
|
235
|
+
self.assertEqual(c.account()["account"]["tier"], "scale")
|
|
236
|
+
self.assertEqual(c.usage(group_by="key", from_="2026-10-01T00:00:00Z")["groupBy"], "key")
|
|
237
|
+
self.assertEqual(query_of(t.calls[1]), {"groupBy": "key", "from": "2026-10-01T00:00:00Z"})
|
|
238
|
+
self.assertEqual(c.status()["incidents"], [])
|
|
239
|
+
self.assertIsNone(t.calls[2].get_header("Authorization"))
|
|
240
|
+
|
|
241
|
+
def test_iter_ledger_pages(self):
|
|
242
|
+
pages = [{"entries": [{"id": "l1"}, {"id": "l2"}], "nextBefore": "l2"}, {"entries": [{"id": "l3"}], "nextBefore": None}]
|
|
243
|
+
c, t = client(lambda r, n: FakeResponse(200, pages[n - 1]))
|
|
244
|
+
self.assertEqual([e["id"] for e in c.iter_ledger(page_size=2, kind="charge")], ["l1", "l2", "l3"])
|
|
245
|
+
self.assertEqual(query_of(t.calls[1]), {"limit": "2", "before": "l2", "kind": "charge"})
|
|
246
|
+
|
|
247
|
+
def test_list_jobs_filters(self):
|
|
248
|
+
c, t = client(lambda r, n: FakeResponse(200, {"jobs": [], "nextBefore": None}))
|
|
249
|
+
c.list_jobs(status=["completed", "refunded"], model_id="m1", key_id="k1")
|
|
250
|
+
self.assertEqual(query_of(t.calls[0]), {"status": "completed,refunded", "modelId": "m1", "keyId": "k1"})
|
|
251
|
+
|
|
252
|
+
def test_models_search_iteration_and_schema(self):
|
|
253
|
+
def handler(r, n):
|
|
254
|
+
if path_of(r) == "/models/model-1":
|
|
255
|
+
return FakeResponse(200, {"model": {"id": "model-1", "inputSchema": {"type": "object"}}})
|
|
256
|
+
return FakeResponse(200, {"models": [{"id": "a"}], "nextCursor": "c2"} if n == 1 else {"models": [{"id": "b"}], "nextCursor": None})
|
|
257
|
+
c, t = client(handler)
|
|
258
|
+
self.assertEqual([m["id"] for m in c.iter_models(q="flux", available=True)], ["a", "b"])
|
|
259
|
+
self.assertEqual(query_of(t.calls[0]), {"q": "flux", "available": "true"})
|
|
260
|
+
self.assertEqual(query_of(t.calls[1])["cursor"], "c2")
|
|
261
|
+
self.assertIsNone(t.calls[0].get_header("Authorization"))
|
|
262
|
+
self.assertEqual(c.model("model-1")["inputSchema"], {"type": "object"})
|
|
263
|
+
c.search_models("portrait", limit=5)
|
|
264
|
+
self.assertEqual(query_of(t.calls[3]), {"q": "portrait", "limit": "5"})
|
|
265
|
+
|
|
266
|
+
def test_cancel_job(self):
|
|
267
|
+
c, t = client(lambda r, n: FakeResponse(200, {"job": {"id": "job-1", "cancelled": True}, "balance": 100, "cancelled": True}))
|
|
268
|
+
self.assertTrue(c.cancel_job("job-1")["cancelled"])
|
|
269
|
+
self.assertEqual((t.calls[0].get_method(), path_of(t.calls[0]), t.calls[0].data), ("POST", "/jobs/job-1/cancel", b"{}"))
|
|
270
|
+
|
|
271
|
+
def test_create_job_webhook_and_wait(self):
|
|
272
|
+
c, t = client(lambda r, n: FakeResponse(200, {"job": {"id": "j", "status": "completed"}, "balance": 1}))
|
|
273
|
+
c.create_job(quote_id="q", input={}, webhook_url="https://hooks.example.com/r", wait=30)
|
|
274
|
+
self.assertEqual(query_of(t.calls[0]), {"wait": "30"})
|
|
275
|
+
self.assertEqual(body(t.calls[0])["webhookUrl"], "https://hooks.example.com/r")
|
|
276
|
+
c.create_job(quote_id="q", input={})
|
|
277
|
+
self.assertEqual(query_of(t.calls[1]), {})
|
|
278
|
+
self.assertNotIn("webhookUrl", body(t.calls[1]))
|
|
279
|
+
|
|
280
|
+
def test_wait_for_job_long_polls(self):
|
|
281
|
+
statuses = ["processing", "completed"]
|
|
282
|
+
c, t = client(lambda r, n: FakeResponse(200, {"job": {"id": "job-1", "status": statuses[n - 1]}}))
|
|
283
|
+
self.assertEqual(c.wait_for_job("job-1", interval=0)["status"], "completed")
|
|
284
|
+
self.assertEqual(query_of(t.calls[0]), {"wait": "60"})
|
|
285
|
+
|
|
286
|
+
def test_wait_for_job_falls_back_without_server_wait(self):
|
|
287
|
+
def handler(r, n):
|
|
288
|
+
if "wait" in query_of(r):
|
|
289
|
+
return http_error(r.full_url, 400, {"error": {"code": "invalid_request", "message": "x"}})
|
|
290
|
+
return FakeResponse(200, {"job": {"id": "job-1", "status": "completed" if n > 2 else "queued"}})
|
|
291
|
+
c, t = client(handler)
|
|
292
|
+
self.assertEqual(c.wait_for_job("job-1", interval=0)["status"], "completed")
|
|
293
|
+
self.assertIs(c.server_wait, False)
|
|
294
|
+
self.assertEqual(sum("wait" in query_of(call) for call in t.calls), 1)
|
|
295
|
+
|
|
296
|
+
def test_wait_for_job_timeout(self):
|
|
297
|
+
c, _ = client(lambda r, n: FakeResponse(200, {"job": {"id": "job-1", "status": "processing"}}))
|
|
298
|
+
with self.assertRaises(RenderlyError) as caught:
|
|
299
|
+
c.wait_for_job("job-1", interval=0.001, timeout=0.01)
|
|
300
|
+
self.assertEqual(caught.exception.code, "wait_timeout")
|
|
301
|
+
|
|
302
|
+
def test_run_completes_inside_server_wait(self):
|
|
303
|
+
def handler(r, n):
|
|
304
|
+
if path_of(r) == "/quotes":
|
|
305
|
+
return FakeResponse(200, {"quoteId": "quote-1", "credits": 12})
|
|
306
|
+
return FakeResponse(200, {"job": {"id": "job-1", "status": "completed"}, "balance": 88})
|
|
307
|
+
c, t = client(handler)
|
|
308
|
+
self.assertEqual(c.run(model_id="m", operation="o", input={}, max_credits=20)["job"]["status"], "completed")
|
|
309
|
+
self.assertEqual(len(t.calls), 2)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
if __name__ == "__main__":
|
|
313
|
+
unittest.main()
|