fittins 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fittins/__init__.py +19 -0
- fittins/_client.py +352 -0
- fittins/_version.py +1 -0
- fittins/errors.py +84 -0
- fittins/py.typed +0 -0
- fittins/types.py +332 -0
- fittins/webhooks.py +62 -0
- fittins-1.0.0.dist-info/METADATA +132 -0
- fittins-1.0.0.dist-info/RECORD +12 -0
- fittins-1.0.0.dist-info/WHEEL +5 -0
- fittins-1.0.0.dist-info/licenses/LICENSE +21 -0
- fittins-1.0.0.dist-info/top_level.txt +1 -0
fittins/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Official Python library for the Fittins AI API."""
|
|
2
|
+
|
|
3
|
+
from . import types
|
|
4
|
+
from ._client import Fittins
|
|
5
|
+
from ._version import __version__
|
|
6
|
+
from .errors import APIConnectionError, APIError, FittinsError, WaitTimeoutError, WebhookVerificationError
|
|
7
|
+
from .webhooks import verify_webhook
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"APIConnectionError",
|
|
11
|
+
"APIError",
|
|
12
|
+
"Fittins",
|
|
13
|
+
"FittinsError",
|
|
14
|
+
"WaitTimeoutError",
|
|
15
|
+
"WebhookVerificationError",
|
|
16
|
+
"__version__",
|
|
17
|
+
"types",
|
|
18
|
+
"verify_webhook",
|
|
19
|
+
]
|
fittins/_client.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import http.client
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import random
|
|
7
|
+
import ssl
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
import uuid
|
|
13
|
+
from typing import TYPE_CHECKING, Any, BinaryIO, Callable, Dict, Iterator, List, Mapping, Optional, Tuple, TypedDict, Union
|
|
14
|
+
|
|
15
|
+
from ._version import __version__
|
|
16
|
+
from .errors import APIConnectionError, APIError, FittinsError, WaitTimeoutError, parse_api_error
|
|
17
|
+
from .types import (
|
|
18
|
+
Account,
|
|
19
|
+
CreateImageParams,
|
|
20
|
+
CreateLocationParams,
|
|
21
|
+
CreateModelParams,
|
|
22
|
+
CreateVideoParams,
|
|
23
|
+
Deleted,
|
|
24
|
+
Generation,
|
|
25
|
+
GenerationStatus,
|
|
26
|
+
GenerationType,
|
|
27
|
+
Location,
|
|
28
|
+
Model,
|
|
29
|
+
Upload,
|
|
30
|
+
Usage,
|
|
31
|
+
WebhookEndpoint,
|
|
32
|
+
WebhookEventType,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from typing_extensions import Literal, Unpack
|
|
37
|
+
|
|
38
|
+
DEFAULT_BASE_URL = "https://api.fittins.ai/v1"
|
|
39
|
+
DEFAULT_TIMEOUT = 120.0
|
|
40
|
+
DEFAULT_MAX_RETRIES = 2
|
|
41
|
+
DEFAULT_WAIT_TIMEOUT = 20 * 60.0
|
|
42
|
+
DEFAULT_WAIT_INTERVAL = 5.0
|
|
43
|
+
# A longer Retry-After than this goes back to the caller instead of being waited out.
|
|
44
|
+
MAX_RETRY_AFTER_SECONDS = 60.0
|
|
45
|
+
RETRY_BASE = 0.5
|
|
46
|
+
RETRY_MAX = 8.0
|
|
47
|
+
TRANSIENT_STATUS = {500, 502, 503, 504}
|
|
48
|
+
FINISHED = {"succeeded", "failed", "canceled"}
|
|
49
|
+
NETWORK_ERRORS = (urllib.error.URLError, http.client.HTTPException, ConnectionError, TimeoutError, ssl.SSLError, OSError)
|
|
50
|
+
|
|
51
|
+
FileInput = Union[str, "os.PathLike[str]", bytes, bytearray, BinaryIO]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ListResponse(TypedDict):
|
|
55
|
+
object: str
|
|
56
|
+
data: List[Any]
|
|
57
|
+
has_more: bool
|
|
58
|
+
next_cursor: Optional[str]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _backoff(attempt: int) -> float:
|
|
62
|
+
ceiling = min(RETRY_MAX, RETRY_BASE * 2**attempt)
|
|
63
|
+
return ceiling / 2 + random.random() * (ceiling / 2)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _retry_delay(error: APIError, repeatable: bool, attempt: int) -> Optional[float]:
|
|
67
|
+
if error.status != 429 and not (repeatable and error.status in TRANSIENT_STATUS):
|
|
68
|
+
return None
|
|
69
|
+
if error.retry_after is None:
|
|
70
|
+
return _backoff(attempt)
|
|
71
|
+
return None if error.retry_after > MAX_RETRY_AFTER_SECONDS else error.retry_after
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _segment(value: str) -> str:
|
|
75
|
+
return urllib.parse.quote(value, safe="")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _multipart(filename: str, content: bytes, content_type: str) -> Tuple[bytes, str]:
|
|
79
|
+
boundary = uuid.uuid4().hex
|
|
80
|
+
safe_name = filename.replace("\\", "_").replace('"', "_").replace("\r", "_").replace("\n", "_")
|
|
81
|
+
head = (
|
|
82
|
+
f"--{boundary}\r\n"
|
|
83
|
+
f'Content-Disposition: form-data; name="file"; filename="{safe_name}"\r\n'
|
|
84
|
+
f"Content-Type: {content_type}\r\n\r\n"
|
|
85
|
+
).encode("utf-8")
|
|
86
|
+
return head + content + f"\r\n--{boundary}--\r\n".encode("ascii"), f"multipart/form-data; boundary={boundary}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _read_file(file: FileInput, filename: Optional[str]) -> Tuple[bytes, str]:
|
|
90
|
+
if isinstance(file, (bytes, bytearray)):
|
|
91
|
+
return bytes(file), filename or "product"
|
|
92
|
+
if isinstance(file, (str, os.PathLike)):
|
|
93
|
+
with open(file, "rb") as handle:
|
|
94
|
+
return handle.read(), filename or os.path.basename(os.fspath(file))
|
|
95
|
+
name = getattr(file, "name", None)
|
|
96
|
+
return file.read(), filename or (os.path.basename(name) if isinstance(name, str) else "product")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _Images:
|
|
100
|
+
def __init__(self, client: "Fittins") -> None:
|
|
101
|
+
self._client = client
|
|
102
|
+
|
|
103
|
+
def create(self, *, idempotency_key: Optional[str] = None, **params: "Unpack[CreateImageParams]") -> Generation:
|
|
104
|
+
"""Create an on-model photo. Answers at once with a queued generation."""
|
|
105
|
+
return self._client._create("/images", params, idempotency_key)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _Videos:
|
|
109
|
+
def __init__(self, client: "Fittins") -> None:
|
|
110
|
+
self._client = client
|
|
111
|
+
|
|
112
|
+
def create(self, *, idempotency_key: Optional[str] = None, **params: "Unpack[CreateVideoParams]") -> Generation:
|
|
113
|
+
"""Create a fashion video. Answers at once with a queued generation."""
|
|
114
|
+
return self._client._create("/videos", params, idempotency_key)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class _Generations:
|
|
118
|
+
def __init__(self, client: "Fittins") -> None:
|
|
119
|
+
self._client = client
|
|
120
|
+
|
|
121
|
+
def list(
|
|
122
|
+
self,
|
|
123
|
+
*,
|
|
124
|
+
status: Optional[GenerationStatus] = None,
|
|
125
|
+
type: Optional[GenerationType] = None,
|
|
126
|
+
limit: Optional[int] = None,
|
|
127
|
+
cursor: Optional[str] = None,
|
|
128
|
+
) -> ListResponse:
|
|
129
|
+
query = {"status": status, "type": type, "limit": limit, "cursor": cursor}
|
|
130
|
+
return self._client._json("GET", "/generations", query=query, repeatable=True)
|
|
131
|
+
|
|
132
|
+
def list_all(
|
|
133
|
+
self,
|
|
134
|
+
*,
|
|
135
|
+
status: Optional[GenerationStatus] = None,
|
|
136
|
+
type: Optional[GenerationType] = None,
|
|
137
|
+
limit: Optional[int] = None,
|
|
138
|
+
) -> Iterator[Generation]:
|
|
139
|
+
"""Every generation matching the filters, across pages, newest first."""
|
|
140
|
+
cursor: Optional[str] = None
|
|
141
|
+
while True:
|
|
142
|
+
page = self.list(status=status, type=type, limit=limit, cursor=cursor)
|
|
143
|
+
yield from page["data"]
|
|
144
|
+
if not page["has_more"] or not page["next_cursor"]:
|
|
145
|
+
return
|
|
146
|
+
cursor = page["next_cursor"]
|
|
147
|
+
|
|
148
|
+
def get(self, id: str) -> Generation:
|
|
149
|
+
return self._client._json("GET", f"/generations/{_segment(id)}", repeatable=True)
|
|
150
|
+
|
|
151
|
+
def delete(self, id: str) -> Union[Generation, Deleted]:
|
|
152
|
+
"""Cancel a queued generation, or delete a finished one with its files."""
|
|
153
|
+
return self._client._json("DELETE", f"/generations/{_segment(id)}")
|
|
154
|
+
|
|
155
|
+
def wait(self, id: str, *, timeout: float = DEFAULT_WAIT_TIMEOUT, interval: float = DEFAULT_WAIT_INTERVAL) -> Generation:
|
|
156
|
+
"""Checks until the generation succeeded, failed or was canceled, and returns it."""
|
|
157
|
+
deadline = time.monotonic() + timeout
|
|
158
|
+
while True:
|
|
159
|
+
generation = self.get(id)
|
|
160
|
+
if generation["status"] in FINISHED:
|
|
161
|
+
return generation
|
|
162
|
+
left = deadline - time.monotonic()
|
|
163
|
+
if left <= 0:
|
|
164
|
+
raise WaitTimeoutError(id, generation["status"], timeout)
|
|
165
|
+
self._client._sleep(min(interval, left))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class _Uploads:
|
|
169
|
+
def __init__(self, client: "Fittins") -> None:
|
|
170
|
+
self._client = client
|
|
171
|
+
|
|
172
|
+
def create(self, file: FileInput, *, filename: Optional[str] = None, content_type: str = "application/octet-stream") -> Upload:
|
|
173
|
+
"""Upload a product photo: a path, bytes or an open file. PNG, JPEG, WebP or HEIC, up to 10 MB."""
|
|
174
|
+
content, name = _read_file(file, filename)
|
|
175
|
+
body, header = _multipart(name, content, content_type)
|
|
176
|
+
return self._client._json("POST", "/uploads", raw=body, content_type=header)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class _Files:
|
|
180
|
+
def __init__(self, client: "Fittins") -> None:
|
|
181
|
+
self._client = client
|
|
182
|
+
|
|
183
|
+
def download(self, file_id: str) -> bytes:
|
|
184
|
+
"""The full-quality file: a PNG or an MP4."""
|
|
185
|
+
return self._client._send("GET", f"/files/{_segment(file_id)}/content", repeatable=True)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class _Models:
|
|
189
|
+
def __init__(self, client: "Fittins") -> None:
|
|
190
|
+
self._client = client
|
|
191
|
+
|
|
192
|
+
def list(self, *, source: "Optional[Literal['library', 'custom']]" = None) -> ListResponse:
|
|
193
|
+
return self._client._json("GET", "/models", query={"source": source}, repeatable=True)
|
|
194
|
+
|
|
195
|
+
def get(self, id: str) -> Model:
|
|
196
|
+
return self._client._json("GET", f"/models/{_segment(id)}", repeatable=True)
|
|
197
|
+
|
|
198
|
+
def create(self, *, idempotency_key: Optional[str] = None, **params: "Unpack[CreateModelParams]") -> Generation:
|
|
199
|
+
"""Create a custom model from a description or a reference photo."""
|
|
200
|
+
return self._client._create("/models", params, idempotency_key)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class _Locations:
|
|
204
|
+
def __init__(self, client: "Fittins") -> None:
|
|
205
|
+
self._client = client
|
|
206
|
+
|
|
207
|
+
def list(self, *, source: "Optional[Literal['library', 'custom']]" = None) -> ListResponse:
|
|
208
|
+
return self._client._json("GET", "/locations", query={"source": source}, repeatable=True)
|
|
209
|
+
|
|
210
|
+
def get(self, id: str) -> Location:
|
|
211
|
+
return self._client._json("GET", f"/locations/{_segment(id)}", repeatable=True)
|
|
212
|
+
|
|
213
|
+
def create(self, *, idempotency_key: Optional[str] = None, **params: "Unpack[CreateLocationParams]") -> Generation:
|
|
214
|
+
"""Create a custom location from a description or a reference photo."""
|
|
215
|
+
return self._client._create("/locations", params, idempotency_key)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class _WebhookEndpoints:
|
|
219
|
+
def __init__(self, client: "Fittins") -> None:
|
|
220
|
+
self._client = client
|
|
221
|
+
|
|
222
|
+
def create(self, *, url: str, events: Optional[List[WebhookEventType]] = None) -> WebhookEndpoint:
|
|
223
|
+
"""The answer holds the signing secret, shown only once."""
|
|
224
|
+
body: Dict[str, Any] = {"url": url}
|
|
225
|
+
if events is not None:
|
|
226
|
+
body["events"] = events
|
|
227
|
+
return self._client._json("POST", "/webhook_endpoints", body=body)
|
|
228
|
+
|
|
229
|
+
def list(self) -> ListResponse:
|
|
230
|
+
return self._client._json("GET", "/webhook_endpoints", repeatable=True)
|
|
231
|
+
|
|
232
|
+
def delete(self, id: str) -> Deleted:
|
|
233
|
+
return self._client._json("DELETE", f"/webhook_endpoints/{_segment(id)}")
|
|
234
|
+
|
|
235
|
+
def rotate_secret(self, id: str) -> WebhookEndpoint:
|
|
236
|
+
"""The answer holds the new secret, shown only once. The old one keeps signing for 24 hours."""
|
|
237
|
+
return self._client._json("POST", f"/webhook_endpoints/{_segment(id)}/rotate_secret")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class _Usage:
|
|
241
|
+
def __init__(self, client: "Fittins") -> None:
|
|
242
|
+
self._client = client
|
|
243
|
+
|
|
244
|
+
def get(self, *, from_: Optional[str] = None, to: Optional[str] = None) -> Usage:
|
|
245
|
+
"""Dates are YYYY-MM-DD."""
|
|
246
|
+
return self._client._json("GET", "/usage", query={"from": from_, "to": to}, repeatable=True)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class _Account:
|
|
250
|
+
def __init__(self, client: "Fittins") -> None:
|
|
251
|
+
self._client = client
|
|
252
|
+
|
|
253
|
+
def get(self) -> Account:
|
|
254
|
+
return self._client._json("GET", "/account", repeatable=True)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class Fittins:
|
|
258
|
+
"""Client for the Fittins AI API. ``api_key`` defaults to FITTINS_API_KEY."""
|
|
259
|
+
|
|
260
|
+
def __init__(
|
|
261
|
+
self,
|
|
262
|
+
api_key: Optional[str] = None,
|
|
263
|
+
*,
|
|
264
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
265
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
266
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
267
|
+
) -> None:
|
|
268
|
+
key = api_key or os.environ.get("FITTINS_API_KEY")
|
|
269
|
+
if not key:
|
|
270
|
+
raise FittinsError("Missing API key. Pass api_key or set FITTINS_API_KEY. Keys are in Profile → API keys on fittins.ai.")
|
|
271
|
+
self.__api_key = key
|
|
272
|
+
self._base_url = base_url.rstrip("/")
|
|
273
|
+
self._max_retries = max_retries
|
|
274
|
+
self._timeout = timeout
|
|
275
|
+
self._sleep: Callable[[float], None] = time.sleep
|
|
276
|
+
|
|
277
|
+
self.images = _Images(self)
|
|
278
|
+
self.videos = _Videos(self)
|
|
279
|
+
self.generations = _Generations(self)
|
|
280
|
+
self.uploads = _Uploads(self)
|
|
281
|
+
self.files = _Files(self)
|
|
282
|
+
self.models = _Models(self)
|
|
283
|
+
self.locations = _Locations(self)
|
|
284
|
+
self.webhook_endpoints = _WebhookEndpoints(self)
|
|
285
|
+
self.usage = _Usage(self)
|
|
286
|
+
self.account = _Account(self)
|
|
287
|
+
|
|
288
|
+
def __repr__(self) -> str:
|
|
289
|
+
return f"Fittins(base_url={self._base_url!r})"
|
|
290
|
+
|
|
291
|
+
def _create(self, path: str, params: Mapping[str, Any], idempotency_key: Optional[str]) -> Any:
|
|
292
|
+
return self._json("POST", path, body=dict(params), idempotency_key=idempotency_key or str(uuid.uuid4()), repeatable=True)
|
|
293
|
+
|
|
294
|
+
def _json(self, method: str, path: str, **call: Any) -> Any:
|
|
295
|
+
return json.loads(self._send(method, path, **call).decode("utf-8"))
|
|
296
|
+
|
|
297
|
+
def _attempt(self, request: urllib.request.Request) -> Tuple[int, Mapping[str, str], bytes]:
|
|
298
|
+
try:
|
|
299
|
+
with urllib.request.urlopen(request, timeout=self._timeout) as response:
|
|
300
|
+
return response.status, response.headers, response.read()
|
|
301
|
+
except urllib.error.HTTPError as error:
|
|
302
|
+
with error:
|
|
303
|
+
return error.code, error.headers, error.read()
|
|
304
|
+
|
|
305
|
+
def _send(
|
|
306
|
+
self,
|
|
307
|
+
method: str,
|
|
308
|
+
path: str,
|
|
309
|
+
*,
|
|
310
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
311
|
+
body: Any = None,
|
|
312
|
+
raw: Optional[bytes] = None,
|
|
313
|
+
content_type: Optional[str] = None,
|
|
314
|
+
idempotency_key: Optional[str] = None,
|
|
315
|
+
repeatable: bool = False,
|
|
316
|
+
) -> bytes:
|
|
317
|
+
url = f"{self._base_url}{path}"
|
|
318
|
+
pairs = [(name, value) for name, value in (query or {}).items() if value is not None]
|
|
319
|
+
if pairs:
|
|
320
|
+
url = f"{url}?{urllib.parse.urlencode(pairs)}"
|
|
321
|
+
|
|
322
|
+
headers = {"Authorization": f"Bearer {self.__api_key}", "User-Agent": f"fittins-python/{__version__}"}
|
|
323
|
+
data = raw
|
|
324
|
+
if body is not None:
|
|
325
|
+
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
326
|
+
headers["Content-Type"] = "application/json"
|
|
327
|
+
elif content_type is not None:
|
|
328
|
+
headers["Content-Type"] = content_type
|
|
329
|
+
if idempotency_key is not None:
|
|
330
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
331
|
+
|
|
332
|
+
attempt = 0
|
|
333
|
+
while True:
|
|
334
|
+
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
335
|
+
try:
|
|
336
|
+
status, response_headers, content = self._attempt(request)
|
|
337
|
+
except NETWORK_ERRORS as error:
|
|
338
|
+
if repeatable and attempt < self._max_retries:
|
|
339
|
+
self._sleep(_backoff(attempt))
|
|
340
|
+
attempt += 1
|
|
341
|
+
continue
|
|
342
|
+
raise APIConnectionError(f"No answer from the Fittins API for {method} {path}.") from error
|
|
343
|
+
|
|
344
|
+
if 200 <= status < 300:
|
|
345
|
+
return content
|
|
346
|
+
|
|
347
|
+
api_error = parse_api_error(status, response_headers, content)
|
|
348
|
+
delay = _retry_delay(api_error, repeatable, attempt)
|
|
349
|
+
if delay is None or attempt >= self._max_retries:
|
|
350
|
+
raise api_error
|
|
351
|
+
self._sleep(delay)
|
|
352
|
+
attempt += 1
|
fittins/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
fittins/errors.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Mapping, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FittinsError(Exception):
|
|
8
|
+
"""Base class for everything this library raises."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class APIError(FittinsError):
|
|
12
|
+
"""The API answered with an error. Branch on ``code``; ``message`` is for people."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
status: int,
|
|
18
|
+
code: str,
|
|
19
|
+
type: str,
|
|
20
|
+
message: str,
|
|
21
|
+
param: Optional[str] = None,
|
|
22
|
+
request_id: Optional[str] = None,
|
|
23
|
+
doc_url: Optional[str] = None,
|
|
24
|
+
retry_after: Optional[float] = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.status = status
|
|
28
|
+
self.code = code
|
|
29
|
+
self.type = type
|
|
30
|
+
self.message = message
|
|
31
|
+
self.param = param
|
|
32
|
+
self.request_id = request_id
|
|
33
|
+
self.doc_url = doc_url
|
|
34
|
+
self.retry_after = retry_after
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class APIConnectionError(FittinsError):
|
|
38
|
+
"""The request never got an answer: network failure or timeout."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class WaitTimeoutError(FittinsError):
|
|
42
|
+
"""``generations.wait`` ran out of time. The generation may still finish."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, generation_id: str, last_status: str, timeout: float) -> None:
|
|
45
|
+
super().__init__(f"Generation {generation_id} was still {last_status} after {round(timeout)} seconds.")
|
|
46
|
+
self.generation_id = generation_id
|
|
47
|
+
self.last_status = last_status
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class WebhookVerificationError(FittinsError):
|
|
51
|
+
"""A webhook failed verification. Answer with a 400 and ignore it."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_api_error(status: int, headers: Mapping[str, str], raw: bytes) -> APIError:
|
|
55
|
+
error: dict[str, Any] = {}
|
|
56
|
+
try:
|
|
57
|
+
parsed = json.loads(raw.decode("utf-8"))
|
|
58
|
+
if isinstance(parsed, dict) and isinstance(parsed.get("error"), dict):
|
|
59
|
+
error = parsed["error"]
|
|
60
|
+
except ValueError:
|
|
61
|
+
pass # Not JSON: a gateway answered instead of the API. Handled by the defaults below.
|
|
62
|
+
|
|
63
|
+
def text(key: str) -> Optional[str]:
|
|
64
|
+
value = error.get(key)
|
|
65
|
+
return value if isinstance(value, str) else None
|
|
66
|
+
|
|
67
|
+
retry_after: Optional[float] = None
|
|
68
|
+
header = headers.get("Retry-After")
|
|
69
|
+
if header is not None:
|
|
70
|
+
try:
|
|
71
|
+
retry_after = float(header)
|
|
72
|
+
except ValueError:
|
|
73
|
+
retry_after = None
|
|
74
|
+
|
|
75
|
+
return APIError(
|
|
76
|
+
status=status,
|
|
77
|
+
code=text("code") or "unexpected_response",
|
|
78
|
+
type=text("type") or "api_error",
|
|
79
|
+
message=text("message") or f"The API answered with HTTP {status}.",
|
|
80
|
+
param=text("param"),
|
|
81
|
+
request_id=text("request_id") or headers.get("X-Request-Id"),
|
|
82
|
+
doc_url=text("doc_url"),
|
|
83
|
+
retry_after=retry_after,
|
|
84
|
+
)
|
fittins/py.typed
ADDED
|
File without changes
|
fittins/types.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# Generated by sdks/generate.mjs from the OpenAPI file. Do not edit: run `node sdks/generate.mjs`.
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
GenerationStatus = Literal["queued", "running", "succeeded", "failed", "canceled"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
GenerationType = Literal["image", "video", "model", "location"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
ImageTier = Literal["turbo", "default", "premium", "ultra"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
ImageQuality = Literal["1k", "2k", "4k", "8k"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
VideoQuality = Literal["1k", "2k", "4k"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
ImageAspectRatio = Literal["1:1", "9:16", "16:9", "3:4", "4:3", "3:2", "2:3"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
VideoAspectRatio = Literal["1:1", "16:9", "9:16"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
VideoDuration = Literal[5, 10]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
WebhookEventType = Literal["generation.succeeded", "generation.failed"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
ErrorCode = Literal["invalid_request", "invalid_image", "invalid_api_key", "insufficient_credits", "spending_cap_reached", "api_not_in_plan", "not_in_plan", "custom_limit_reached", "storage_full", "not_found", "method_not_allowed", "idempotency_conflict", "generation_running", "content_flagged", "protected_content", "rate_limited", "concurrency_limit", "generation_failed", "internal_error"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class GenerationOutput(TypedDict, total=False):
|
|
39
|
+
file_id: str
|
|
40
|
+
url: str
|
|
41
|
+
url_expires_at: str
|
|
42
|
+
format: Literal["png", "mp4"]
|
|
43
|
+
width: Union[int, None]
|
|
44
|
+
height: Union[int, None]
|
|
45
|
+
quality: "ImageQuality"
|
|
46
|
+
title: Union[str, None]
|
|
47
|
+
alt_text: Union[str, None]
|
|
48
|
+
# Videos.
|
|
49
|
+
duration_seconds: Union[int, None]
|
|
50
|
+
# Videos.
|
|
51
|
+
has_sound: bool
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class GenerationCredits(TypedDict, total=False):
|
|
55
|
+
estimated: int
|
|
56
|
+
charged: int
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class GenerationError(TypedDict, total=False):
|
|
60
|
+
code: "ErrorCode"
|
|
61
|
+
message: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Generation(TypedDict, total=False):
|
|
65
|
+
id: str
|
|
66
|
+
object: Literal["generation"]
|
|
67
|
+
type: "GenerationType"
|
|
68
|
+
status: "GenerationStatus"
|
|
69
|
+
created_at: str
|
|
70
|
+
started_at: Union[str, None]
|
|
71
|
+
completed_at: Union[str, None]
|
|
72
|
+
input: Dict[str, Any]
|
|
73
|
+
output: Union[None, "GenerationOutput"]
|
|
74
|
+
credits: "GenerationCredits"
|
|
75
|
+
project_id: Union[str, None]
|
|
76
|
+
metadata: Dict[str, str]
|
|
77
|
+
error: Union[None, "GenerationError"]
|
|
78
|
+
livemode: bool
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ImageFromUrl(TypedDict, total=False):
|
|
82
|
+
url: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ImageFromUpload(TypedDict, total=False):
|
|
86
|
+
upload_id: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
ImageRef = Union["ImageFromUrl", "ImageFromUpload"]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Model(TypedDict, total=False):
|
|
93
|
+
id: str
|
|
94
|
+
object: Literal["model"]
|
|
95
|
+
source: Literal["library", "custom"]
|
|
96
|
+
name: str
|
|
97
|
+
image_url: Union[str, None]
|
|
98
|
+
created_at: Union[str, None]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Location(TypedDict, total=False):
|
|
102
|
+
id: str
|
|
103
|
+
object: Literal["location"]
|
|
104
|
+
source: Literal["library", "custom"]
|
|
105
|
+
name: str
|
|
106
|
+
image_url: Union[str, None]
|
|
107
|
+
created_at: Union[str, None]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Upload(TypedDict, total=False):
|
|
111
|
+
id: str
|
|
112
|
+
object: Literal["upload"]
|
|
113
|
+
filename: str
|
|
114
|
+
content_type: str
|
|
115
|
+
size_bytes: int
|
|
116
|
+
width: Union[int, None]
|
|
117
|
+
height: Union[int, None]
|
|
118
|
+
created_at: str
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class AccountPlan(TypedDict, total=False):
|
|
122
|
+
id: str
|
|
123
|
+
name: str
|
|
124
|
+
billing_cycle: Optional[Literal["monthly", "annual"]]
|
|
125
|
+
renews_at: Union[str, None]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class AccountCredits(TypedDict, total=False):
|
|
129
|
+
balance: int
|
|
130
|
+
# Held by running and queued generations.
|
|
131
|
+
reserved: int
|
|
132
|
+
available: int
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class AccountFeatures(TypedDict, total=False):
|
|
136
|
+
image_tiers: List[str]
|
|
137
|
+
image_qualities: List[str]
|
|
138
|
+
video_tiers: List[str]
|
|
139
|
+
video_qualities: List[str]
|
|
140
|
+
custom_models_max: int
|
|
141
|
+
custom_locations_max: int
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class AccountLimits(TypedDict, total=False):
|
|
145
|
+
concurrent_generations: int
|
|
146
|
+
queue_max: int
|
|
147
|
+
requests_per_minute: int
|
|
148
|
+
creates_per_minute: int
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class AccountStorage(TypedDict, total=False):
|
|
152
|
+
used_bytes: int
|
|
153
|
+
limit_bytes: int
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Account(TypedDict, total=False):
|
|
157
|
+
object: Literal["account"]
|
|
158
|
+
plan: "AccountPlan"
|
|
159
|
+
credits: "AccountCredits"
|
|
160
|
+
features: "AccountFeatures"
|
|
161
|
+
limits: "AccountLimits"
|
|
162
|
+
storage: "AccountStorage"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class UsageTotals(TypedDict, total=False):
|
|
166
|
+
requests: int
|
|
167
|
+
generations: int
|
|
168
|
+
credits: int
|
|
169
|
+
failures: int
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class UsageKey(TypedDict, total=False):
|
|
173
|
+
name: str
|
|
174
|
+
last4: Union[str, None]
|
|
175
|
+
mode: Union[str, None]
|
|
176
|
+
requests: int
|
|
177
|
+
generations: int
|
|
178
|
+
credits: int
|
|
179
|
+
failures: int
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class UsageDay(TypedDict, total=False):
|
|
183
|
+
date: str
|
|
184
|
+
requests: int
|
|
185
|
+
generations: int
|
|
186
|
+
credits: int
|
|
187
|
+
failures: int
|
|
188
|
+
keys: List["UsageKey"]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
Usage = TypedDict("Usage", {
|
|
192
|
+
"object": Literal["usage"],
|
|
193
|
+
"from": str,
|
|
194
|
+
"to": str,
|
|
195
|
+
"totals": "UsageTotals",
|
|
196
|
+
"days": List["UsageDay"],
|
|
197
|
+
}, total=False)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class Deleted(TypedDict, total=False):
|
|
201
|
+
id: str
|
|
202
|
+
object: str
|
|
203
|
+
deleted: Literal[True]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class WebhookEndpointLastDelivery(TypedDict, total=False):
|
|
207
|
+
at: Union[str, None]
|
|
208
|
+
status: str
|
|
209
|
+
http_status: Union[int, None]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class WebhookEndpoint(TypedDict, total=False):
|
|
213
|
+
id: str
|
|
214
|
+
object: Literal["webhook_endpoint"]
|
|
215
|
+
url: str
|
|
216
|
+
events: List["WebhookEventType"]
|
|
217
|
+
status: Literal["enabled", "paused"]
|
|
218
|
+
livemode: bool
|
|
219
|
+
created_at: str
|
|
220
|
+
last_delivery: Union[None, "WebhookEndpointLastDelivery"]
|
|
221
|
+
# Only when created or rotated.
|
|
222
|
+
secret: str
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class WebhookEventData(TypedDict, total=False):
|
|
226
|
+
object: "Generation"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class WebhookEvent(TypedDict, total=False):
|
|
230
|
+
id: str
|
|
231
|
+
object: Literal["event"]
|
|
232
|
+
type: "WebhookEventType"
|
|
233
|
+
created_at: str
|
|
234
|
+
livemode: bool
|
|
235
|
+
data: "WebhookEventData"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class ErrorError(TypedDict, total=False):
|
|
239
|
+
type: Literal["invalid_request_error", "authentication_error", "credits_error", "permission_error", "not_found_error", "idempotency_error", "conflict_error", "content_error", "rate_limit_error", "generation_error", "api_error"]
|
|
240
|
+
code: "ErrorCode"
|
|
241
|
+
message: str
|
|
242
|
+
param: Union[str, None]
|
|
243
|
+
request_id: str
|
|
244
|
+
doc_url: str
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class Error(TypedDict, total=False):
|
|
248
|
+
error: "ErrorError"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class _CreateImageParamsRequired(TypedDict):
|
|
252
|
+
# 1 to 3 photos of the product: { "url": "https://…" } or { "upload_id": "upl_…" }. Flat lays, hanger and ghost-mannequin shots and packshots all work. Different angles of one item are fine; another colour of the same item goes in a separate generation.
|
|
253
|
+
product_images: List["ImageRef"]
|
|
254
|
+
# A library model (mdl_lib_…) or one of your custom models (mdl_own_…). List them with GET /v1/models.
|
|
255
|
+
model_id: str
|
|
256
|
+
# 3 to 1500 characters: pose, framing, light, mood, setting.
|
|
257
|
+
prompt: str
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class CreateImageParams(_CreateImageParamsRequired, total=False):
|
|
261
|
+
# A library or custom location (loc_lib_…, loc_own_…). Leave it out to describe the setting in the prompt.
|
|
262
|
+
location_id: Union[str, None]
|
|
263
|
+
aspect_ratio: "ImageAspectRatio"
|
|
264
|
+
tier: "ImageTier"
|
|
265
|
+
quality: "ImageQuality"
|
|
266
|
+
# Your own references, returned unchanged: up to 20 keys (letters, digits, - or _, up to 40 characters), values up to 500 characters. metadata.sku (or reference) is shown on the result in My Generations.
|
|
267
|
+
metadata: Dict[str, str]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class _CreateVideoParamsRequired(TypedDict):
|
|
271
|
+
# 1 to 3 photos of the product: { "url": "https://…" } or { "upload_id": "upl_…" }. Flat lays, hanger and ghost-mannequin shots and packshots all work. Different angles of one item are fine; another colour of the same item goes in a separate generation.
|
|
272
|
+
product_images: List["ImageRef"]
|
|
273
|
+
# A library model (mdl_lib_…) or one of your custom models (mdl_own_…). List them with GET /v1/models.
|
|
274
|
+
model_id: str
|
|
275
|
+
# 3 to 1500 characters: the motion and setting, for example "model walks towards the camera".
|
|
276
|
+
prompt: str
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class CreateVideoParams(_CreateVideoParamsRequired, total=False):
|
|
280
|
+
# A library or custom location (loc_lib_…, loc_own_…). Leave it out to describe the setting in the prompt.
|
|
281
|
+
location_id: Union[str, None]
|
|
282
|
+
aspect_ratio: "VideoAspectRatio"
|
|
283
|
+
duration_seconds: "VideoDuration"
|
|
284
|
+
tier: "ImageTier"
|
|
285
|
+
quality: "VideoQuality"
|
|
286
|
+
# AI sound effects (ambient and foley, not voiceover or music). 10 credits.
|
|
287
|
+
sound: bool
|
|
288
|
+
# Your own references, returned unchanged: up to 20 keys (letters, digits, - or _, up to 40 characters), values up to 500 characters. metadata.sku (or reference) is shown on the result in My Generations.
|
|
289
|
+
metadata: Dict[str, str]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class CreateModelParams(TypedDict, total=False):
|
|
293
|
+
# 3 to 1500 characters describing the model or place. Needed unless you send reference_image.
|
|
294
|
+
description: str
|
|
295
|
+
reference_image: "ImageRef"
|
|
296
|
+
# Up to 60 characters. Without one, we name it.
|
|
297
|
+
name: str
|
|
298
|
+
# Your own references, returned unchanged: up to 20 keys (letters, digits, - or _, up to 40 characters), values up to 500 characters. metadata.sku (or reference) is shown on the result in My Generations.
|
|
299
|
+
metadata: Dict[str, str]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class CreateLocationParams(TypedDict, total=False):
|
|
303
|
+
# 3 to 1500 characters describing the model or place. Needed unless you send reference_image.
|
|
304
|
+
description: str
|
|
305
|
+
reference_image: "ImageRef"
|
|
306
|
+
# Up to 60 characters. Without one, we name it.
|
|
307
|
+
name: str
|
|
308
|
+
# Your own references, returned unchanged: up to 20 keys (letters, digits, - or _, up to 40 characters), values up to 500 characters. metadata.sku (or reference) is shown on the result in My Generations.
|
|
309
|
+
metadata: Dict[str, str]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class _CreateWebhookEndpointParamsRequired(TypedDict):
|
|
313
|
+
url: str
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class CreateWebhookEndpointParams(_CreateWebhookEndpointParamsRequired, total=False):
|
|
317
|
+
events: List["WebhookEventType"]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class ListGenerationsParams(TypedDict, total=False):
|
|
321
|
+
status: "GenerationStatus"
|
|
322
|
+
type: "GenerationType"
|
|
323
|
+
limit: int
|
|
324
|
+
cursor: str
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class ListModelsParams(TypedDict, total=False):
|
|
328
|
+
source: Literal["library", "custom"]
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class ListLocationsParams(TypedDict, total=False):
|
|
332
|
+
source: Literal["library", "custom"]
|
fittins/webhooks.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Optional, Union
|
|
8
|
+
|
|
9
|
+
from .errors import WebhookVerificationError
|
|
10
|
+
from .types import WebhookEvent
|
|
11
|
+
|
|
12
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def verify_webhook(
|
|
16
|
+
raw_body: Union[str, bytes],
|
|
17
|
+
signature_header: Optional[str],
|
|
18
|
+
secret: str,
|
|
19
|
+
*,
|
|
20
|
+
tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS,
|
|
21
|
+
now: Optional[float] = None,
|
|
22
|
+
) -> WebhookEvent:
|
|
23
|
+
"""Checks a delivery's ``Fittins-Signature`` header and returns the event.
|
|
24
|
+
|
|
25
|
+
Pass the body exactly as received, before any JSON parsing. Raises
|
|
26
|
+
WebhookVerificationError when the signature or the time is wrong.
|
|
27
|
+
"""
|
|
28
|
+
if isinstance(raw_body, str):
|
|
29
|
+
body = raw_body.encode("utf-8")
|
|
30
|
+
elif isinstance(raw_body, (bytes, bytearray)):
|
|
31
|
+
body = bytes(raw_body)
|
|
32
|
+
else:
|
|
33
|
+
raise WebhookVerificationError("Pass the raw request body as a string or bytes, before any JSON parsing.")
|
|
34
|
+
if not secret:
|
|
35
|
+
raise WebhookVerificationError("Missing the endpoint's signing secret.")
|
|
36
|
+
if not signature_header:
|
|
37
|
+
raise WebhookVerificationError("Missing the Fittins-Signature header.")
|
|
38
|
+
|
|
39
|
+
fields = [part.split("=", 1) if "=" in part else [part, ""] for part in signature_header.split(",")]
|
|
40
|
+
fields = [[name.strip(), value.strip()] for name, value in fields]
|
|
41
|
+
timestamp = next((value for name, value in fields if name == "t"), "")
|
|
42
|
+
signatures = [value for name, value in fields if name == "v1"]
|
|
43
|
+
if not (timestamp.isascii() and timestamp.isdigit()) or not signatures:
|
|
44
|
+
raise WebhookVerificationError("The Fittins-Signature header is not valid.")
|
|
45
|
+
|
|
46
|
+
signed = timestamp.encode("ascii") + b"." + body
|
|
47
|
+
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
|
|
48
|
+
matched = False
|
|
49
|
+
for signature in signatures:
|
|
50
|
+
if hmac.compare_digest(signature.lower().encode("utf-8"), expected.encode("ascii")):
|
|
51
|
+
matched = True
|
|
52
|
+
if not matched:
|
|
53
|
+
raise WebhookVerificationError("The signature does not match. Check the secret and that the body was not changed.")
|
|
54
|
+
|
|
55
|
+
current = time.time() if now is None else now
|
|
56
|
+
if abs(current - int(timestamp)) > tolerance_seconds:
|
|
57
|
+
raise WebhookVerificationError("The delivery is too old, or this server's clock is wrong.")
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
return json.loads(body.decode("utf-8"))
|
|
61
|
+
except ValueError as error:
|
|
62
|
+
raise WebhookVerificationError("The delivery is not valid JSON.") from error
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fittins
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python library for the Fittins AI API: premium AI fashion photos and videos from your product photos.
|
|
5
|
+
Author-email: Fittins AI <support@fittins.ai>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://fittins.ai
|
|
8
|
+
Project-URL: Documentation, https://fittins.ai/docs/api
|
|
9
|
+
Project-URL: Support, https://fittins.ai/support
|
|
10
|
+
Keywords: fittins,ai,fashion,on-model,virtual-try-on,product-photos,fashion-video,api,sdk
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Fittins AI for Python
|
|
20
|
+
|
|
21
|
+
The official library for the [Fittins AI API](https://fittins.ai/docs/api). Fittins AI is a premium AI fashion generation service: studio-quality on-model photos up to 8K and fashion videos up to 4K, made from your product photos.
|
|
22
|
+
|
|
23
|
+
The API is included in the Pro, Business and Enterprise plans and uses the same credits as the website. Create a key in **Profile → API keys** on [fittins.ai](https://fittins.ai).
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install fittins
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Needs Python 3.10 or later, and has no dependencies. Use it on your server: never put an API key in a web page or an app.
|
|
30
|
+
|
|
31
|
+
## Create a photo
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from fittins import Fittins
|
|
35
|
+
|
|
36
|
+
fittins = Fittins() # reads FITTINS_API_KEY
|
|
37
|
+
|
|
38
|
+
generation = fittins.images.create(
|
|
39
|
+
product_images=[{"url": "https://cdn.mystore.com/p/dress.jpg"}],
|
|
40
|
+
model_id="mdl_lib_DM5rQvl3KtlR7nqumd6I",
|
|
41
|
+
prompt="full body, soft studio light, front view",
|
|
42
|
+
aspect_ratio="3:4",
|
|
43
|
+
quality="4k",
|
|
44
|
+
metadata={"sku": "DRESS-RED-M"},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
done = fittins.generations.wait(generation["id"])
|
|
48
|
+
|
|
49
|
+
if done["status"] == "succeeded":
|
|
50
|
+
print(done["output"]["url"]) # a download link, valid for 24 hours
|
|
51
|
+
else:
|
|
52
|
+
print(done["error"]["code"], done["error"]["message"])
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## What's included
|
|
56
|
+
|
|
57
|
+
| | |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `images.create` · `videos.create` | Start a photo or a video |
|
|
60
|
+
| `generations.get` · `list` · `list_all` · `wait` · `delete` | Follow, list and delete results |
|
|
61
|
+
| `uploads.create` | Upload a product photo (a path, bytes or an open file) |
|
|
62
|
+
| `files.download` | Get a result's bytes |
|
|
63
|
+
| `models` · `locations` | `list`, `get`, and `create` a custom one |
|
|
64
|
+
| `webhook_endpoints` | `create`, `list`, `delete`, `rotate_secret` |
|
|
65
|
+
| `usage.get` · `account.get` | Calls and credits per day, and plan and balance |
|
|
66
|
+
|
|
67
|
+
Answers are plain dictionaries, with type hints in `fittins.types`. Field names are the same as in the [API reference](https://fittins.ai/docs/api).
|
|
68
|
+
|
|
69
|
+
## Retries and safe creates
|
|
70
|
+
|
|
71
|
+
Failed requests are sent up to 2 more times, with a growing pause. After a 429, the library waits for the time in `Retry-After`.
|
|
72
|
+
|
|
73
|
+
Each create gets an `Idempotency-Key` that its retries reuse, so a retry can't charge twice. To protect against your own job running twice, pass a key you can repeat:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
fittins.images.create(idempotency_key=f"photo-{sku}", **params)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Uploads, webhook endpoint changes and deletes are only retried after a 429, because sending them twice could do the work twice.
|
|
80
|
+
|
|
81
|
+
Set your own limits with `Fittins(max_retries=4, timeout=60.0)`.
|
|
82
|
+
|
|
83
|
+
## Errors
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from fittins import APIError
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
fittins.images.create(**params)
|
|
90
|
+
except APIError as error:
|
|
91
|
+
if error.code == "insufficient_credits":
|
|
92
|
+
... # error.status, error.code, error.message, error.param, error.request_id
|
|
93
|
+
else:
|
|
94
|
+
raise
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`APIConnectionError` means no answer arrived. `WaitTimeoutError` means `generations.wait` ran out of time; the generation may still finish. A generation that fails after being accepted isn't raised: check `status` and `error`.
|
|
98
|
+
|
|
99
|
+
## Webhooks
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
import os
|
|
103
|
+
|
|
104
|
+
from flask import Flask, request
|
|
105
|
+
from fittins import WebhookVerificationError, verify_webhook
|
|
106
|
+
|
|
107
|
+
app = Flask(__name__)
|
|
108
|
+
|
|
109
|
+
@app.post("/webhooks/fittins")
|
|
110
|
+
def fittins_webhook():
|
|
111
|
+
try:
|
|
112
|
+
event = verify_webhook(
|
|
113
|
+
request.get_data(),
|
|
114
|
+
request.headers.get("Fittins-Signature"),
|
|
115
|
+
os.environ["FITTINS_WEBHOOK_SECRET"],
|
|
116
|
+
)
|
|
117
|
+
except WebhookVerificationError:
|
|
118
|
+
return "", 400
|
|
119
|
+
if event["type"] == "generation.succeeded":
|
|
120
|
+
print(event["data"]["object"]["output"]["url"])
|
|
121
|
+
return "", 200
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Pass the body exactly as received, before any JSON parsing.
|
|
125
|
+
|
|
126
|
+
## Testing without credits
|
|
127
|
+
|
|
128
|
+
Keys that start with `fit_test_` run the checks on your request and return a sample result, without using the AI or any credits.
|
|
129
|
+
|
|
130
|
+
## More
|
|
131
|
+
|
|
132
|
+
[API documentation](https://fittins.ai/docs/api) · [Support](https://fittins.ai/support)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
fittins/__init__.py,sha256=D9cSQiBgpkAH9yIgD0_CdAXA_S60Eb3wCEa7zMPFNeo,484
|
|
2
|
+
fittins/_client.py,sha256=0DE3lNMyFtcc05I1EUR8CLoN2fmMXzlxTs6dpwgu8Tk,13555
|
|
3
|
+
fittins/_version.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
|
|
4
|
+
fittins/errors.py,sha256=6Tzc9WaZZL0hzijz2uDW-bnfaX6ikA60oUBlREpGCPc,2665
|
|
5
|
+
fittins/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
fittins/types.py,sha256=6DjS20bsth8_2dPcVhcExL1Drx0PJRMUkxcoO3zHAhk,9759
|
|
7
|
+
fittins/webhooks.py,sha256=pogoucU2bAyOAJlYqRq6lBXQneUh3XbCBxAdOqCGuQk,2495
|
|
8
|
+
fittins-1.0.0.dist-info/licenses/LICENSE,sha256=X572e2sNZA9cP1R0bomRhHQINYjn9nDUIo9z-aHI-bg,1067
|
|
9
|
+
fittins-1.0.0.dist-info/METADATA,sha256=bFo7Usjci1rPRX1_sftfWq11g97VZmWzIvhDg4_yS2I,4628
|
|
10
|
+
fittins-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
11
|
+
fittins-1.0.0.dist-info/top_level.txt,sha256=sTyUqSDpFpCyzKK9_ARezR8uxnJEB1TVjpcwAq5tjG4,8
|
|
12
|
+
fittins-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fittins AI
|
|
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 @@
|
|
|
1
|
+
fittins
|