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 +21 -0
- fittins-1.0.0/PKG-INFO +132 -0
- fittins-1.0.0/README.md +114 -0
- fittins-1.0.0/pyproject.toml +34 -0
- fittins-1.0.0/setup.cfg +4 -0
- fittins-1.0.0/src/fittins/__init__.py +19 -0
- fittins-1.0.0/src/fittins/_client.py +352 -0
- fittins-1.0.0/src/fittins/_version.py +1 -0
- fittins-1.0.0/src/fittins/errors.py +84 -0
- fittins-1.0.0/src/fittins/py.typed +0 -0
- fittins-1.0.0/src/fittins/types.py +332 -0
- fittins-1.0.0/src/fittins/webhooks.py +62 -0
- fittins-1.0.0/src/fittins.egg-info/PKG-INFO +132 -0
- fittins-1.0.0/src/fittins.egg-info/SOURCES.txt +16 -0
- fittins-1.0.0/src/fittins.egg-info/dependency_links.txt +1 -0
- fittins-1.0.0/src/fittins.egg-info/top_level.txt +1 -0
- fittins-1.0.0/tests/test_client.py +317 -0
- fittins-1.0.0/tests/test_webhooks.py +53 -0
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)
|
fittins-1.0.0/README.md
ADDED
|
@@ -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"]
|
fittins-1.0.0/setup.cfg
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
|
+
]
|
|
@@ -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"
|