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 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)