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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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