fittins 1.0.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.
fittins-1.0.0/LICENSE ADDED
@@ -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.
fittins-1.0.0/PKG-INFO ADDED
@@ -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,114 @@
1
+ # Fittins AI for Python
2
+
3
+ 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.
4
+
5
+ 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).
6
+
7
+ ```bash
8
+ pip install fittins
9
+ ```
10
+
11
+ 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.
12
+
13
+ ## Create a photo
14
+
15
+ ```python
16
+ from fittins import Fittins
17
+
18
+ fittins = Fittins() # reads FITTINS_API_KEY
19
+
20
+ generation = fittins.images.create(
21
+ product_images=[{"url": "https://cdn.mystore.com/p/dress.jpg"}],
22
+ model_id="mdl_lib_DM5rQvl3KtlR7nqumd6I",
23
+ prompt="full body, soft studio light, front view",
24
+ aspect_ratio="3:4",
25
+ quality="4k",
26
+ metadata={"sku": "DRESS-RED-M"},
27
+ )
28
+
29
+ done = fittins.generations.wait(generation["id"])
30
+
31
+ if done["status"] == "succeeded":
32
+ print(done["output"]["url"]) # a download link, valid for 24 hours
33
+ else:
34
+ print(done["error"]["code"], done["error"]["message"])
35
+ ```
36
+
37
+ ## What's included
38
+
39
+ | | |
40
+ |---|---|
41
+ | `images.create` · `videos.create` | Start a photo or a video |
42
+ | `generations.get` · `list` · `list_all` · `wait` · `delete` | Follow, list and delete results |
43
+ | `uploads.create` | Upload a product photo (a path, bytes or an open file) |
44
+ | `files.download` | Get a result's bytes |
45
+ | `models` · `locations` | `list`, `get`, and `create` a custom one |
46
+ | `webhook_endpoints` | `create`, `list`, `delete`, `rotate_secret` |
47
+ | `usage.get` · `account.get` | Calls and credits per day, and plan and balance |
48
+
49
+ 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).
50
+
51
+ ## Retries and safe creates
52
+
53
+ 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`.
54
+
55
+ 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:
56
+
57
+ ```python
58
+ fittins.images.create(idempotency_key=f"photo-{sku}", **params)
59
+ ```
60
+
61
+ Uploads, webhook endpoint changes and deletes are only retried after a 429, because sending them twice could do the work twice.
62
+
63
+ Set your own limits with `Fittins(max_retries=4, timeout=60.0)`.
64
+
65
+ ## Errors
66
+
67
+ ```python
68
+ from fittins import APIError
69
+
70
+ try:
71
+ fittins.images.create(**params)
72
+ except APIError as error:
73
+ if error.code == "insufficient_credits":
74
+ ... # error.status, error.code, error.message, error.param, error.request_id
75
+ else:
76
+ raise
77
+ ```
78
+
79
+ `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`.
80
+
81
+ ## Webhooks
82
+
83
+ ```python
84
+ import os
85
+
86
+ from flask import Flask, request
87
+ from fittins import WebhookVerificationError, verify_webhook
88
+
89
+ app = Flask(__name__)
90
+
91
+ @app.post("/webhooks/fittins")
92
+ def fittins_webhook():
93
+ try:
94
+ event = verify_webhook(
95
+ request.get_data(),
96
+ request.headers.get("Fittins-Signature"),
97
+ os.environ["FITTINS_WEBHOOK_SECRET"],
98
+ )
99
+ except WebhookVerificationError:
100
+ return "", 400
101
+ if event["type"] == "generation.succeeded":
102
+ print(event["data"]["object"]["output"]["url"])
103
+ return "", 200
104
+ ```
105
+
106
+ Pass the body exactly as received, before any JSON parsing.
107
+
108
+ ## Testing without credits
109
+
110
+ Keys that start with `fit_test_` run the checks on your request and return a sample result, without using the AI or any credits.
111
+
112
+ ## More
113
+
114
+ [API documentation](https://fittins.ai/docs/api) · [Support](https://fittins.ai/support)
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "fittins"
7
+ dynamic = ["version"]
8
+ description = "Official Python library for the Fittins AI API: premium AI fashion photos and videos from your product photos."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "Fittins AI", email = "support@fittins.ai" }]
14
+ keywords = ["fittins", "ai", "fashion", "on-model", "virtual-try-on", "product-photos", "fashion-video", "api", "sdk"]
15
+ classifiers = [
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Typing :: Typed",
19
+ ]
20
+ dependencies = []
21
+
22
+ [project.urls]
23
+ Homepage = "https://fittins.ai"
24
+ Documentation = "https://fittins.ai/docs/api"
25
+ Support = "https://fittins.ai/support"
26
+
27
+ [tool.setuptools.dynamic]
28
+ version = { attr = "fittins._version.__version__" }
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+
33
+ [tool.setuptools.package-data]
34
+ fittins = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]
@@ -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
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"