lastshoot 0.1.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.
- lastshoot/__init__.py +63 -0
- lastshoot/_client.py +490 -0
- lastshoot/_endpoints.py +1520 -0
- lastshoot/_exceptions.py +168 -0
- lastshoot/_models.py +154 -0
- lastshoot/_version.py +1 -0
- lastshoot/py.typed +0 -0
- lastshoot-0.1.0.dist-info/METADATA +193 -0
- lastshoot-0.1.0.dist-info/RECORD +11 -0
- lastshoot-0.1.0.dist-info/WHEEL +4 -0
- lastshoot-0.1.0.dist-info/licenses/LICENSE +21 -0
lastshoot/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Official Python client for the LastShoot API (https://www.lastshoot.com)."""
|
|
2
|
+
|
|
3
|
+
from ._client import DEFAULT_BASE_URL, AsyncLastShoot, FileInput, LastShoot
|
|
4
|
+
from ._endpoints import (
|
|
5
|
+
AdapterConfigSnapshot,
|
|
6
|
+
JobQueuedData,
|
|
7
|
+
JobStatusData,
|
|
8
|
+
Profile,
|
|
9
|
+
ProfileList,
|
|
10
|
+
UploadData,
|
|
11
|
+
WebhookEvent,
|
|
12
|
+
)
|
|
13
|
+
from ._exceptions import (
|
|
14
|
+
APIConnectionError,
|
|
15
|
+
APIError,
|
|
16
|
+
APITimeoutError,
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
InsufficientCreditsError,
|
|
19
|
+
InternalServerError,
|
|
20
|
+
InvalidRequestError,
|
|
21
|
+
JobFailedError,
|
|
22
|
+
JobTimeoutError,
|
|
23
|
+
LastShootError,
|
|
24
|
+
NotFoundError,
|
|
25
|
+
PermissionDeniedError,
|
|
26
|
+
RateLimitError,
|
|
27
|
+
)
|
|
28
|
+
from ._models import AsyncJob, Job, JobStatus, ProgressCallback, Result, UploadResult
|
|
29
|
+
from ._version import __version__
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"__version__",
|
|
33
|
+
"DEFAULT_BASE_URL",
|
|
34
|
+
"LastShoot",
|
|
35
|
+
"AsyncLastShoot",
|
|
36
|
+
"FileInput",
|
|
37
|
+
"Job",
|
|
38
|
+
"AsyncJob",
|
|
39
|
+
"JobStatus",
|
|
40
|
+
"Result",
|
|
41
|
+
"UploadResult",
|
|
42
|
+
"ProgressCallback",
|
|
43
|
+
"AdapterConfigSnapshot",
|
|
44
|
+
"JobQueuedData",
|
|
45
|
+
"JobStatusData",
|
|
46
|
+
"Profile",
|
|
47
|
+
"ProfileList",
|
|
48
|
+
"UploadData",
|
|
49
|
+
"WebhookEvent",
|
|
50
|
+
"LastShootError",
|
|
51
|
+
"APIError",
|
|
52
|
+
"InvalidRequestError",
|
|
53
|
+
"AuthenticationError",
|
|
54
|
+
"InsufficientCreditsError",
|
|
55
|
+
"PermissionDeniedError",
|
|
56
|
+
"NotFoundError",
|
|
57
|
+
"RateLimitError",
|
|
58
|
+
"InternalServerError",
|
|
59
|
+
"APIConnectionError",
|
|
60
|
+
"APITimeoutError",
|
|
61
|
+
"JobFailedError",
|
|
62
|
+
"JobTimeoutError",
|
|
63
|
+
]
|
lastshoot/_client.py
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import mimetypes
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import IO, Any, Callable, Dict, Optional, Tuple, Union
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from ._endpoints import (
|
|
15
|
+
AsyncBackgroundResource,
|
|
16
|
+
AsyncBrandingResource,
|
|
17
|
+
AsyncEditResource,
|
|
18
|
+
AsyncGenerateResource,
|
|
19
|
+
AsyncIdentityResource,
|
|
20
|
+
AsyncUpscaleResource,
|
|
21
|
+
BackgroundResource,
|
|
22
|
+
BrandingResource,
|
|
23
|
+
EditResource,
|
|
24
|
+
GenerateResource,
|
|
25
|
+
IdentityResource,
|
|
26
|
+
UpscaleResource,
|
|
27
|
+
_seg,
|
|
28
|
+
)
|
|
29
|
+
from ._exceptions import (
|
|
30
|
+
APIConnectionError,
|
|
31
|
+
APITimeoutError,
|
|
32
|
+
JobFailedError,
|
|
33
|
+
JobTimeoutError,
|
|
34
|
+
LastShootError,
|
|
35
|
+
make_api_error,
|
|
36
|
+
)
|
|
37
|
+
from ._models import AsyncJob, Job, JobStatus, ProgressCallback, Result, UploadResult
|
|
38
|
+
from ._version import __version__
|
|
39
|
+
|
|
40
|
+
__all__ = ["LastShoot", "AsyncLastShoot", "DEFAULT_BASE_URL", "FileInput"]
|
|
41
|
+
|
|
42
|
+
DEFAULT_BASE_URL = "https://api.lastshoot.com"
|
|
43
|
+
DEFAULT_TIMEOUT = 60.0
|
|
44
|
+
DEFAULT_MAX_RETRIES = 2
|
|
45
|
+
DEFAULT_POLL_INTERVAL = 2.0
|
|
46
|
+
DEFAULT_WAIT_TIMEOUT = 600.0
|
|
47
|
+
UPLOAD_PATH = "/upload/image/v1"
|
|
48
|
+
|
|
49
|
+
FileInput = Union[str, "os.PathLike[str]", bytes, bytearray, IO[bytes]]
|
|
50
|
+
|
|
51
|
+
_RETRY_STATUSES = {429, 500, 502, 503, 504}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _compact(body: Dict[str, Any]) -> Dict[str, Any]:
|
|
55
|
+
"""Drop None values so server-side defaults apply."""
|
|
56
|
+
return {k: v for k, v in body.items() if v is not None}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _backoff(attempt: int, retry_after: Optional[float]) -> float:
|
|
60
|
+
if retry_after is not None and retry_after >= 0:
|
|
61
|
+
return min(retry_after, 30.0)
|
|
62
|
+
return min(0.5 * float(2**attempt), 8.0) + random.uniform(0, 0.25)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _retry_after(resp: httpx.Response) -> Optional[float]:
|
|
66
|
+
raw = resp.headers.get("retry-after")
|
|
67
|
+
try:
|
|
68
|
+
return float(raw) if raw else None
|
|
69
|
+
except ValueError:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _decode(resp: httpx.Response) -> Any:
|
|
74
|
+
if not resp.content:
|
|
75
|
+
return None
|
|
76
|
+
try:
|
|
77
|
+
return resp.json()
|
|
78
|
+
except ValueError:
|
|
79
|
+
return resp.text
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _file_part(
|
|
83
|
+
file: FileInput, filename: Optional[str], content_type: Optional[str]
|
|
84
|
+
) -> Tuple[str, bytes, str]:
|
|
85
|
+
if isinstance(file, (str, os.PathLike)):
|
|
86
|
+
path = Path(file)
|
|
87
|
+
if not path.is_file():
|
|
88
|
+
raise LastShootError(f"upload: no such file: {path}")
|
|
89
|
+
data = path.read_bytes()
|
|
90
|
+
name = filename or path.name
|
|
91
|
+
elif isinstance(file, (bytes, bytearray)):
|
|
92
|
+
data = bytes(file)
|
|
93
|
+
name = filename or "image"
|
|
94
|
+
elif hasattr(file, "read"):
|
|
95
|
+
data = file.read()
|
|
96
|
+
if isinstance(data, str):
|
|
97
|
+
raise LastShootError("upload: open the file in binary mode ('rb')")
|
|
98
|
+
name = filename or Path(getattr(file, "name", "image") or "image").name
|
|
99
|
+
else:
|
|
100
|
+
raise LastShootError(f"upload: unsupported file input {type(file).__name__}")
|
|
101
|
+
ctype = content_type or mimetypes.guess_type(name)[0] or "application/octet-stream"
|
|
102
|
+
return name, data, ctype
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class _Base:
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
api_key: Optional[str] = None,
|
|
109
|
+
*,
|
|
110
|
+
base_url: Optional[str] = None,
|
|
111
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
112
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
113
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
114
|
+
wait_timeout: float = DEFAULT_WAIT_TIMEOUT,
|
|
115
|
+
default_headers: Optional[Dict[str, str]] = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
api_key = api_key or os.environ.get("LASTSHOOT_API_KEY")
|
|
118
|
+
if not api_key:
|
|
119
|
+
raise LastShootError(
|
|
120
|
+
"No API key. Pass LastShoot(api_key=...) or set LASTSHOOT_API_KEY. "
|
|
121
|
+
"Get one at https://www.lastshoot.com/dashboard"
|
|
122
|
+
)
|
|
123
|
+
self.api_key = api_key
|
|
124
|
+
self.base_url = (base_url or os.environ.get("LASTSHOOT_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
125
|
+
self.timeout = timeout
|
|
126
|
+
self.max_retries = max_retries
|
|
127
|
+
self.poll_interval = poll_interval
|
|
128
|
+
self.wait_timeout = wait_timeout
|
|
129
|
+
self._headers = {
|
|
130
|
+
"X-API-Key": api_key,
|
|
131
|
+
"Accept": "application/json",
|
|
132
|
+
"User-Agent": f"lastshoot-python/{__version__}",
|
|
133
|
+
**(default_headers or {}),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
def __repr__(self) -> str:
|
|
137
|
+
return f"{type(self).__name__}(base_url={self.base_url!r})"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class _UploadResource:
|
|
141
|
+
"""`client.upload.image(file)`; `client.upload(file)` is the same call."""
|
|
142
|
+
|
|
143
|
+
def __init__(self, client: "LastShoot") -> None:
|
|
144
|
+
self._client = client
|
|
145
|
+
|
|
146
|
+
def image(
|
|
147
|
+
self, file: FileInput, *, filename: Optional[str] = None, content_type: Optional[str] = None
|
|
148
|
+
) -> UploadResult:
|
|
149
|
+
"""Upload a local image (path, bytes or binary file object). Returns a hosted `url`.
|
|
150
|
+
|
|
151
|
+
PNG, JPEG, WebP or GIF, max 20 MB. `POST /upload/image/v1`
|
|
152
|
+
"""
|
|
153
|
+
part = _file_part(file, filename, content_type)
|
|
154
|
+
data = self._client._request_json("POST", UPLOAD_PATH, files={"file": part})
|
|
155
|
+
return UploadResult.from_dict(data)
|
|
156
|
+
|
|
157
|
+
__call__ = image
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class _AsyncUploadResource:
|
|
161
|
+
def __init__(self, client: "AsyncLastShoot") -> None:
|
|
162
|
+
self._client = client
|
|
163
|
+
|
|
164
|
+
async def image(
|
|
165
|
+
self, file: FileInput, *, filename: Optional[str] = None, content_type: Optional[str] = None
|
|
166
|
+
) -> UploadResult:
|
|
167
|
+
"""Upload a local image (path, bytes or binary file object). Returns a hosted `url`."""
|
|
168
|
+
part = _file_part(file, filename, content_type)
|
|
169
|
+
data = await self._client._request_json("POST", UPLOAD_PATH, files={"file": part})
|
|
170
|
+
return UploadResult.from_dict(data)
|
|
171
|
+
|
|
172
|
+
__call__ = image
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class _JobsResource:
|
|
176
|
+
def __init__(self, client: "LastShoot") -> None:
|
|
177
|
+
self._client = client
|
|
178
|
+
|
|
179
|
+
def get(self, endpoint: str, job_id: str) -> JobStatus:
|
|
180
|
+
"""Fetch a job's status. `endpoint` is the submit path, e.g. `/edit/image/v1`."""
|
|
181
|
+
endpoint = "/" + endpoint.strip("/")
|
|
182
|
+
data = self._client._request_json("GET", f"{endpoint}/status/{_seg(job_id)}")
|
|
183
|
+
return JobStatus.from_dict(data or {}, job_id=job_id, endpoint=endpoint)
|
|
184
|
+
|
|
185
|
+
def wait(
|
|
186
|
+
self,
|
|
187
|
+
endpoint: str,
|
|
188
|
+
job_id: str,
|
|
189
|
+
*,
|
|
190
|
+
poll_interval: Optional[float] = None,
|
|
191
|
+
timeout: Optional[float] = None,
|
|
192
|
+
on_progress: Optional[ProgressCallback] = None,
|
|
193
|
+
) -> Result:
|
|
194
|
+
"""Poll until the job is completed (returns Result) or failed/cancelled (raises JobFailedError)."""
|
|
195
|
+
c = self._client
|
|
196
|
+
interval = c.poll_interval if poll_interval is None else poll_interval
|
|
197
|
+
limit = c.wait_timeout if timeout is None else timeout
|
|
198
|
+
deadline = c._clock() + limit
|
|
199
|
+
last: Optional[JobStatus] = None
|
|
200
|
+
while True:
|
|
201
|
+
last = self.get(endpoint, job_id)
|
|
202
|
+
if on_progress is not None:
|
|
203
|
+
on_progress(last)
|
|
204
|
+
if last.status == "completed":
|
|
205
|
+
return Result(**{k: getattr(last, k) for k in last.__dataclass_fields__})
|
|
206
|
+
if last.is_terminal:
|
|
207
|
+
raise JobFailedError(last)
|
|
208
|
+
if c._clock() + interval > deadline:
|
|
209
|
+
raise JobTimeoutError(job_id, limit, last)
|
|
210
|
+
c._sleep(interval)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class _AsyncJobsResource:
|
|
214
|
+
def __init__(self, client: "AsyncLastShoot") -> None:
|
|
215
|
+
self._client = client
|
|
216
|
+
|
|
217
|
+
async def get(self, endpoint: str, job_id: str) -> JobStatus:
|
|
218
|
+
endpoint = "/" + endpoint.strip("/")
|
|
219
|
+
data = await self._client._request_json("GET", f"{endpoint}/status/{_seg(job_id)}")
|
|
220
|
+
return JobStatus.from_dict(data or {}, job_id=job_id, endpoint=endpoint)
|
|
221
|
+
|
|
222
|
+
async def wait(
|
|
223
|
+
self,
|
|
224
|
+
endpoint: str,
|
|
225
|
+
job_id: str,
|
|
226
|
+
*,
|
|
227
|
+
poll_interval: Optional[float] = None,
|
|
228
|
+
timeout: Optional[float] = None,
|
|
229
|
+
on_progress: Optional[ProgressCallback] = None,
|
|
230
|
+
) -> Result:
|
|
231
|
+
c = self._client
|
|
232
|
+
interval = c.poll_interval if poll_interval is None else poll_interval
|
|
233
|
+
limit = c.wait_timeout if timeout is None else timeout
|
|
234
|
+
deadline = c._clock() + limit
|
|
235
|
+
while True:
|
|
236
|
+
last = await self.get(endpoint, job_id)
|
|
237
|
+
if on_progress is not None:
|
|
238
|
+
maybe = on_progress(last)
|
|
239
|
+
if inspect.isawaitable(maybe):
|
|
240
|
+
await maybe
|
|
241
|
+
if last.status == "completed":
|
|
242
|
+
return Result(**{k: getattr(last, k) for k in last.__dataclass_fields__})
|
|
243
|
+
if last.is_terminal:
|
|
244
|
+
raise JobFailedError(last)
|
|
245
|
+
if c._clock() + interval > deadline:
|
|
246
|
+
raise JobTimeoutError(job_id, limit, last)
|
|
247
|
+
await c._sleep(interval)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class LastShoot(_Base):
|
|
251
|
+
"""Synchronous LastShoot client.
|
|
252
|
+
|
|
253
|
+
>>> from lastshoot import LastShoot
|
|
254
|
+
>>> ip = LastShoot("ip_live_...") # or set LASTSHOOT_API_KEY
|
|
255
|
+
>>> result = ip.generate.image(prompt="fashion model, white studio background")
|
|
256
|
+
>>> result.url
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
def __init__(
|
|
260
|
+
self,
|
|
261
|
+
api_key: Optional[str] = None,
|
|
262
|
+
*,
|
|
263
|
+
base_url: Optional[str] = None,
|
|
264
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
265
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
266
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
267
|
+
wait_timeout: float = DEFAULT_WAIT_TIMEOUT,
|
|
268
|
+
default_headers: Optional[Dict[str, str]] = None,
|
|
269
|
+
http_client: Optional[httpx.Client] = None,
|
|
270
|
+
) -> None:
|
|
271
|
+
super().__init__(
|
|
272
|
+
api_key,
|
|
273
|
+
base_url=base_url,
|
|
274
|
+
timeout=timeout,
|
|
275
|
+
max_retries=max_retries,
|
|
276
|
+
poll_interval=poll_interval,
|
|
277
|
+
wait_timeout=wait_timeout,
|
|
278
|
+
default_headers=default_headers,
|
|
279
|
+
)
|
|
280
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
281
|
+
self._owns_http = http_client is None
|
|
282
|
+
self._sleep: Callable[[float], None] = time.sleep
|
|
283
|
+
self._clock: Callable[[], float] = time.monotonic
|
|
284
|
+
self.generate = GenerateResource(self)
|
|
285
|
+
self.edit = EditResource(self)
|
|
286
|
+
self.background = BackgroundResource(self)
|
|
287
|
+
self.upscale = UpscaleResource(self)
|
|
288
|
+
self.branding = BrandingResource(self)
|
|
289
|
+
self.identity = IdentityResource(self)
|
|
290
|
+
self.upload = _UploadResource(self)
|
|
291
|
+
self.jobs = _JobsResource(self)
|
|
292
|
+
|
|
293
|
+
# -- lifecycle
|
|
294
|
+
def close(self) -> None:
|
|
295
|
+
if self._owns_http:
|
|
296
|
+
self._http.close()
|
|
297
|
+
|
|
298
|
+
def __enter__(self) -> "LastShoot":
|
|
299
|
+
return self
|
|
300
|
+
|
|
301
|
+
def __exit__(self, *exc: Any) -> None:
|
|
302
|
+
self.close()
|
|
303
|
+
|
|
304
|
+
# -- transport
|
|
305
|
+
def _request_json(
|
|
306
|
+
self,
|
|
307
|
+
method: str,
|
|
308
|
+
path: str,
|
|
309
|
+
*,
|
|
310
|
+
json: Optional[Dict[str, Any]] = None,
|
|
311
|
+
files: Optional[Dict[str, Any]] = None,
|
|
312
|
+
) -> Any:
|
|
313
|
+
url = self.base_url + path
|
|
314
|
+
body = _compact(json) if json is not None else None
|
|
315
|
+
# Only GET is retried: a retried POST could run (and bill) a job twice.
|
|
316
|
+
retries = self.max_retries if method == "GET" else 0
|
|
317
|
+
attempt = 0
|
|
318
|
+
while True:
|
|
319
|
+
try:
|
|
320
|
+
resp = self._http.request(method, url, json=body, files=files, headers=self._headers)
|
|
321
|
+
except httpx.TimeoutException as e:
|
|
322
|
+
if attempt < retries:
|
|
323
|
+
self._sleep(_backoff(attempt, None))
|
|
324
|
+
attempt += 1
|
|
325
|
+
continue
|
|
326
|
+
raise APITimeoutError(f"{method} {path} timed out") from e
|
|
327
|
+
except httpx.HTTPError as e:
|
|
328
|
+
if attempt < retries:
|
|
329
|
+
self._sleep(_backoff(attempt, None))
|
|
330
|
+
attempt += 1
|
|
331
|
+
continue
|
|
332
|
+
raise APIConnectionError(f"{method} {path} failed: {e}") from e
|
|
333
|
+
if resp.status_code in _RETRY_STATUSES and attempt < retries:
|
|
334
|
+
self._sleep(_backoff(attempt, _retry_after(resp)))
|
|
335
|
+
attempt += 1
|
|
336
|
+
continue
|
|
337
|
+
if resp.status_code >= 400:
|
|
338
|
+
raise make_api_error(resp.status_code, _decode(resp), resp.headers)
|
|
339
|
+
return _decode(resp)
|
|
340
|
+
|
|
341
|
+
def _submit(self, path: str, body: Dict[str, Any]) -> Job:
|
|
342
|
+
data = self._request_json("POST", path, json=body) or {}
|
|
343
|
+
if not data.get("job_id"):
|
|
344
|
+
raise LastShootError(f"POST {path} returned no job_id: {data!r}")
|
|
345
|
+
return Job(
|
|
346
|
+
job_id=data["job_id"],
|
|
347
|
+
endpoint=path,
|
|
348
|
+
status=data.get("status") or "queued",
|
|
349
|
+
estimated_time_seconds=data.get("estimated_time_seconds"),
|
|
350
|
+
estimated_cost_usd=data.get("estimated_cost_usd"),
|
|
351
|
+
queued_at=data.get("queued_at"),
|
|
352
|
+
raw=data,
|
|
353
|
+
_client=self,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
def _run(
|
|
357
|
+
self,
|
|
358
|
+
path: str,
|
|
359
|
+
body: Dict[str, Any],
|
|
360
|
+
*,
|
|
361
|
+
wait: bool,
|
|
362
|
+
poll_interval: Optional[float],
|
|
363
|
+
timeout: Optional[float],
|
|
364
|
+
on_progress: Optional[ProgressCallback],
|
|
365
|
+
) -> Union[Result, Job]:
|
|
366
|
+
job = self._submit(path, body)
|
|
367
|
+
if not wait:
|
|
368
|
+
return job
|
|
369
|
+
return job.wait(poll_interval=poll_interval, timeout=timeout, on_progress=on_progress)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
async def _async_sleep(seconds: float) -> None:
|
|
373
|
+
await asyncio.sleep(seconds)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class AsyncLastShoot(_Base):
|
|
377
|
+
"""Asyncio LastShoot client. Same surface as `LastShoot`, every call is awaitable.
|
|
378
|
+
|
|
379
|
+
>>> async with AsyncLastShoot() as ip:
|
|
380
|
+
... result = await ip.edit.image(input_image=url, prompt="make the dress emerald")
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
def __init__(
|
|
384
|
+
self,
|
|
385
|
+
api_key: Optional[str] = None,
|
|
386
|
+
*,
|
|
387
|
+
base_url: Optional[str] = None,
|
|
388
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
389
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
390
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
391
|
+
wait_timeout: float = DEFAULT_WAIT_TIMEOUT,
|
|
392
|
+
default_headers: Optional[Dict[str, str]] = None,
|
|
393
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
394
|
+
) -> None:
|
|
395
|
+
super().__init__(
|
|
396
|
+
api_key,
|
|
397
|
+
base_url=base_url,
|
|
398
|
+
timeout=timeout,
|
|
399
|
+
max_retries=max_retries,
|
|
400
|
+
poll_interval=poll_interval,
|
|
401
|
+
wait_timeout=wait_timeout,
|
|
402
|
+
default_headers=default_headers,
|
|
403
|
+
)
|
|
404
|
+
self._http = http_client or httpx.AsyncClient(timeout=timeout)
|
|
405
|
+
self._owns_http = http_client is None
|
|
406
|
+
self._sleep: Callable[[float], Any] = _async_sleep
|
|
407
|
+
self._clock: Callable[[], float] = time.monotonic
|
|
408
|
+
self.generate = AsyncGenerateResource(self)
|
|
409
|
+
self.edit = AsyncEditResource(self)
|
|
410
|
+
self.background = AsyncBackgroundResource(self)
|
|
411
|
+
self.upscale = AsyncUpscaleResource(self)
|
|
412
|
+
self.branding = AsyncBrandingResource(self)
|
|
413
|
+
self.identity = AsyncIdentityResource(self)
|
|
414
|
+
self.upload = _AsyncUploadResource(self)
|
|
415
|
+
self.jobs = _AsyncJobsResource(self)
|
|
416
|
+
|
|
417
|
+
async def close(self) -> None:
|
|
418
|
+
if self._owns_http:
|
|
419
|
+
await self._http.aclose()
|
|
420
|
+
|
|
421
|
+
async def __aenter__(self) -> "AsyncLastShoot":
|
|
422
|
+
return self
|
|
423
|
+
|
|
424
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
425
|
+
await self.close()
|
|
426
|
+
|
|
427
|
+
async def _request_json(
|
|
428
|
+
self,
|
|
429
|
+
method: str,
|
|
430
|
+
path: str,
|
|
431
|
+
*,
|
|
432
|
+
json: Optional[Dict[str, Any]] = None,
|
|
433
|
+
files: Optional[Dict[str, Any]] = None,
|
|
434
|
+
) -> Any:
|
|
435
|
+
url = self.base_url + path
|
|
436
|
+
body = _compact(json) if json is not None else None
|
|
437
|
+
retries = self.max_retries if method == "GET" else 0
|
|
438
|
+
attempt = 0
|
|
439
|
+
while True:
|
|
440
|
+
try:
|
|
441
|
+
resp = await self._http.request(method, url, json=body, files=files, headers=self._headers)
|
|
442
|
+
except httpx.TimeoutException as e:
|
|
443
|
+
if attempt < retries:
|
|
444
|
+
await self._sleep(_backoff(attempt, None))
|
|
445
|
+
attempt += 1
|
|
446
|
+
continue
|
|
447
|
+
raise APITimeoutError(f"{method} {path} timed out") from e
|
|
448
|
+
except httpx.HTTPError as e:
|
|
449
|
+
if attempt < retries:
|
|
450
|
+
await self._sleep(_backoff(attempt, None))
|
|
451
|
+
attempt += 1
|
|
452
|
+
continue
|
|
453
|
+
raise APIConnectionError(f"{method} {path} failed: {e}") from e
|
|
454
|
+
if resp.status_code in _RETRY_STATUSES and attempt < retries:
|
|
455
|
+
await self._sleep(_backoff(attempt, _retry_after(resp)))
|
|
456
|
+
attempt += 1
|
|
457
|
+
continue
|
|
458
|
+
if resp.status_code >= 400:
|
|
459
|
+
raise make_api_error(resp.status_code, _decode(resp), resp.headers)
|
|
460
|
+
return _decode(resp)
|
|
461
|
+
|
|
462
|
+
async def _submit(self, path: str, body: Dict[str, Any]) -> AsyncJob:
|
|
463
|
+
data = await self._request_json("POST", path, json=body) or {}
|
|
464
|
+
if not data.get("job_id"):
|
|
465
|
+
raise LastShootError(f"POST {path} returned no job_id: {data!r}")
|
|
466
|
+
return AsyncJob(
|
|
467
|
+
job_id=data["job_id"],
|
|
468
|
+
endpoint=path,
|
|
469
|
+
status=data.get("status") or "queued",
|
|
470
|
+
estimated_time_seconds=data.get("estimated_time_seconds"),
|
|
471
|
+
estimated_cost_usd=data.get("estimated_cost_usd"),
|
|
472
|
+
queued_at=data.get("queued_at"),
|
|
473
|
+
raw=data,
|
|
474
|
+
_client=self,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
async def _run(
|
|
478
|
+
self,
|
|
479
|
+
path: str,
|
|
480
|
+
body: Dict[str, Any],
|
|
481
|
+
*,
|
|
482
|
+
wait: bool,
|
|
483
|
+
poll_interval: Optional[float],
|
|
484
|
+
timeout: Optional[float],
|
|
485
|
+
on_progress: Optional[ProgressCallback],
|
|
486
|
+
) -> Union[Result, AsyncJob]:
|
|
487
|
+
job = await self._submit(path, body)
|
|
488
|
+
if not wait:
|
|
489
|
+
return job
|
|
490
|
+
return await job.wait(poll_interval=poll_interval, timeout=timeout, on_progress=on_progress)
|