sume-client 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.
@@ -0,0 +1,74 @@
1
+ from sume_client.exceptions import (
2
+ CredentialsMissedError,
3
+ SumeClientError,
4
+ )
5
+ from sume_client.http.client import (
6
+ AsyncClient,
7
+ AsyncRequestController,
8
+ DONE_STATUSES,
9
+ SyncClient,
10
+ SyncRequestController,
11
+ )
12
+ from sume_client.types_ import (
13
+ Cancelled,
14
+ Completed,
15
+ Failed,
16
+ InProgress,
17
+ NSFW,
18
+ Queued,
19
+ Status,
20
+ )
21
+
22
+ sync_client = SyncClient()
23
+ submit = sync_client.submit
24
+ subscribe = sync_client.subscribe
25
+ status = sync_client.status
26
+ result = sync_client.result
27
+ cancel = sync_client.cancel
28
+ upload = sync_client.upload
29
+ upload_file = sync_client.upload_file
30
+ upload_image = sync_client.upload_image
31
+
32
+ async_client = AsyncClient()
33
+ submit_async = async_client.submit
34
+ subscribe_async = async_client.subscribe
35
+ status_async = async_client.status
36
+ result_async = async_client.result
37
+ cancel_async = async_client.cancel
38
+ upload_async = async_client.upload
39
+ upload_file_async = async_client.upload_file
40
+ upload_image_async = async_client.upload_image
41
+
42
+
43
+ __all__ = [
44
+ 'CredentialsMissedError',
45
+ 'SumeClientError',
46
+ 'SyncRequestController',
47
+ 'AsyncRequestController',
48
+ 'SyncClient',
49
+ 'AsyncClient',
50
+ 'DONE_STATUSES',
51
+ 'Queued',
52
+ 'InProgress',
53
+ 'Completed',
54
+ 'Status',
55
+ 'Failed',
56
+ 'Cancelled',
57
+ 'NSFW',
58
+ 'submit',
59
+ 'subscribe',
60
+ 'status',
61
+ 'result',
62
+ 'cancel',
63
+ 'upload',
64
+ 'upload_file',
65
+ 'upload_image',
66
+ 'submit_async',
67
+ 'subscribe_async',
68
+ 'status_async',
69
+ 'result_async',
70
+ 'cancel_async',
71
+ 'upload_async',
72
+ 'upload_file_async',
73
+ 'upload_image_async',
74
+ ]
sume_client/auth.py ADDED
@@ -0,0 +1,15 @@
1
+ import os
2
+
3
+ from sume_client.exceptions import CredentialsMissedError
4
+
5
+
6
+ def get_credential_key() -> str:
7
+ key = os.getenv('SUME_API_KEY', '').strip()
8
+
9
+ if key:
10
+ return key
11
+
12
+ raise CredentialsMissedError(
13
+ 'Sume API credentials missing. Please set SUME_API_KEY to a key '
14
+ 'from https://open.sume.com/api-keys.'
15
+ )
@@ -0,0 +1,22 @@
1
+ from typing import Any, Optional
2
+
3
+
4
+ class SumeClientError(Exception):
5
+ def __init__(
6
+ self,
7
+ message: str,
8
+ *,
9
+ status_code: Optional[int] = None,
10
+ code: Optional[str] = None,
11
+ request_id: Optional[str] = None,
12
+ details: Any = None,
13
+ ) -> None:
14
+ super().__init__(message)
15
+ self.status_code = status_code
16
+ self.code = code
17
+ self.request_id = request_id
18
+ self.details = details
19
+
20
+
21
+ class CredentialsMissedError(Exception):
22
+ pass
File without changes
@@ -0,0 +1,495 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import time
6
+ import uuid
7
+ import weakref
8
+ from functools import cached_property
9
+ from typing import AsyncIterator, Callable, Dict, Iterator, Optional, Tuple, TYPE_CHECKING
10
+ from urllib.parse import quote
11
+
12
+ import httpx
13
+
14
+ from sume_client.auth import get_credential_key
15
+ from sume_client.http.error import to_storage_error
16
+ from sume_client.http.retry import create_default_retry_strategy
17
+ from sume_client.http.transport import AsyncHttpTransport, HttpTransport
18
+ from sume_client.mixins import UploadMixin
19
+ from sume_client.types_ import (
20
+ AnyJSON,
21
+ Cancelled,
22
+ Completed,
23
+ Failed,
24
+ NSFW,
25
+ Status,
26
+ )
27
+ from sume_client.utils import get_arguments, get_catalog_url, get_router_url, get_status
28
+
29
+ if TYPE_CHECKING:
30
+ from PIL import Image
31
+
32
+
33
+ DEFAULT_TIMEOUT = 90.0
34
+ DEFAULT_POLLING_DELAY = 2.0
35
+
36
+ BASE_URL = 'https://api.open.sume.com'
37
+ USER_AGENT = 'sume-client-py/0.1.0'
38
+ DONE_STATUSES = (Completed, NSFW, Cancelled, Failed)
39
+ UPLOAD_URL = '/v1/files/generate-upload-url'
40
+
41
+
42
+ class _NoLoop:
43
+ pass
44
+
45
+
46
+ _NO_LOOP = _NoLoop()
47
+
48
+
49
+ def _running_loop() -> object:
50
+ try:
51
+ return asyncio.get_running_loop()
52
+ except RuntimeError:
53
+ return _NO_LOOP
54
+
55
+
56
+ class cached_per_loop:
57
+ def __init__(self, func: Callable) -> None:
58
+ self.func = func
59
+ self.key = f'_{func.__name__}_per_loop'
60
+
61
+ def __get__(self, instance, owner=None):
62
+ if instance is None:
63
+ return self
64
+
65
+ values = instance.__dict__.setdefault(self.key, weakref.WeakKeyDictionary())
66
+ loop = _running_loop()
67
+ value = values.get(loop)
68
+
69
+ if value is None:
70
+ value = values.setdefault(loop, self.func(instance))
71
+
72
+ return value
73
+
74
+
75
+ class RequestController:
76
+ def __init__(
77
+ self,
78
+ request_id: str,
79
+ response_url: Optional[str] = None,
80
+ status_url: Optional[str] = None,
81
+ cancel_url: Optional[str] = None,
82
+ ) -> None:
83
+ self.request_id = request_id
84
+
85
+ urls = self.build_urls(BASE_URL, request_id)
86
+
87
+ self.response_url = response_url or urls['response_url']
88
+ self.status_url = status_url or urls['status_url']
89
+ self.cancel_url = cancel_url or urls['cancel_url']
90
+
91
+ @staticmethod
92
+ def build_urls(base_url: str, request_id: str) -> Dict[str, str]:
93
+ if request_id in ('', '.', '..'):
94
+ raise ValueError(f'{request_id!r} is not a request id')
95
+
96
+ request_base = f"{base_url.rstrip('/')}/v1/jobs/{quote(request_id, safe='')}"
97
+ return {
98
+ 'status_url': f'{request_base}/status',
99
+ 'response_url': f'{request_base}/result',
100
+ 'cancel_url': f'{request_base}/cancel',
101
+ }
102
+
103
+
104
+ class SyncRequestController(RequestController):
105
+ def __init__(
106
+ self,
107
+ request_id: str,
108
+ transport: HttpTransport,
109
+ response_url: Optional[str] = None,
110
+ status_url: Optional[str] = None,
111
+ cancel_url: Optional[str] = None,
112
+ ) -> None:
113
+ super().__init__(
114
+ request_id=request_id,
115
+ response_url=response_url,
116
+ status_url=status_url,
117
+ cancel_url=cancel_url,
118
+ )
119
+ self._transport = transport
120
+
121
+ def cancel(self) -> None:
122
+ self._transport.request('POST', self.cancel_url)
123
+
124
+ def status(self) -> Status:
125
+ return get_status(self._get_status_data())
126
+
127
+ def _get_status_data(self) -> AnyJSON:
128
+ response = self._transport.request('GET', self.status_url)
129
+ return response.json()['data']
130
+
131
+ def _poll(self, delay: float) -> Iterator[Tuple[Status, AnyJSON]]:
132
+ while True:
133
+ data = self._get_status_data()
134
+ status = get_status(data)
135
+ yield status, data
136
+
137
+ if isinstance(status, DONE_STATUSES):
138
+ break
139
+
140
+ time.sleep(delay)
141
+
142
+ def poll_request_status(self, *, delay: float = DEFAULT_POLLING_DELAY) -> Iterator[Status]:
143
+ for status, _ in self._poll(delay):
144
+ yield status
145
+
146
+ def get(self) -> AnyJSON:
147
+ for status, data in self._poll(DEFAULT_POLLING_DELAY):
148
+ continue
149
+
150
+ if not isinstance(status, Completed):
151
+ return data
152
+
153
+ response = self._transport.request('GET', self.response_url)
154
+ return response.json()['data']
155
+
156
+ def __repr__(self):
157
+ return f'SyncRequestController(request_id={self.request_id})'
158
+
159
+
160
+ class AsyncRequestController(RequestController):
161
+ def __init__(
162
+ self,
163
+ request_id: str,
164
+ transport: AsyncHttpTransport,
165
+ response_url: Optional[str] = None,
166
+ status_url: Optional[str] = None,
167
+ cancel_url: Optional[str] = None,
168
+ ) -> None:
169
+ super().__init__(
170
+ request_id=request_id,
171
+ response_url=response_url,
172
+ status_url=status_url,
173
+ cancel_url=cancel_url,
174
+ )
175
+ self._transport = transport
176
+
177
+ async def cancel(self) -> None:
178
+ await self._transport.request('POST', self.cancel_url)
179
+
180
+ async def status(self) -> Status:
181
+ return get_status(await self._get_status_data())
182
+
183
+ async def _get_status_data(self) -> AnyJSON:
184
+ response = await self._transport.request('GET', self.status_url)
185
+ return response.json()['data']
186
+
187
+ async def _poll(self, delay: float) -> AsyncIterator[Tuple[Status, AnyJSON]]:
188
+ while True:
189
+ data = await self._get_status_data()
190
+ status = get_status(data)
191
+ yield status, data
192
+
193
+ if isinstance(status, DONE_STATUSES):
194
+ break
195
+
196
+ await asyncio.sleep(delay)
197
+
198
+ async def poll_request_status(
199
+ self,
200
+ *,
201
+ delay: float = DEFAULT_POLLING_DELAY,
202
+ ) -> AsyncIterator[Status]:
203
+ async for status, _ in self._poll(delay):
204
+ yield status
205
+
206
+ async def get(self) -> AnyJSON:
207
+ async for status, data in self._poll(DEFAULT_POLLING_DELAY):
208
+ continue
209
+
210
+ if not isinstance(status, Completed):
211
+ return data
212
+
213
+ response = await self._transport.request('GET', self.response_url)
214
+ return response.json()['data']
215
+
216
+ def __repr__(self):
217
+ return f'AsyncRequestController(request_id={self.request_id})'
218
+
219
+
220
+ class SyncClient(UploadMixin):
221
+ def __init__(
222
+ self,
223
+ base_url: str = BASE_URL,
224
+ timeout: float = DEFAULT_TIMEOUT,
225
+ api_key: str | None = None,
226
+ ):
227
+ self.base_url = base_url
228
+ self.timeout = timeout
229
+ self.api_key = api_key
230
+ self._model_kinds: Dict[str, str] = {}
231
+
232
+ @cached_property
233
+ def _client(self) -> httpx.Client:
234
+ api_key = self.api_key or get_credential_key()
235
+
236
+ return httpx.Client(
237
+ headers={
238
+ 'Authorization': f'Bearer {api_key}',
239
+ 'Content-Type': 'application/json',
240
+ 'User-Agent': USER_AGENT,
241
+ },
242
+ timeout=self.timeout,
243
+ base_url=self.base_url,
244
+ )
245
+
246
+ @cached_property
247
+ def _upload_client(self) -> httpx.Client:
248
+ return httpx.Client(timeout=self.timeout)
249
+
250
+ @cached_property
251
+ def _transport(self) -> HttpTransport:
252
+ retry_strategy = create_default_retry_strategy()
253
+ return HttpTransport(self._client, retry_strategy)
254
+
255
+ def get_request_controller(self, request_id: str) -> SyncRequestController:
256
+ urls = RequestController.build_urls(self.base_url, request_id)
257
+ return SyncRequestController(request_id, self._transport, **urls)
258
+
259
+ def _get_model_kind(self, application: str) -> str:
260
+ if application not in self._model_kinds:
261
+ response = self._transport.request('GET', get_catalog_url(application))
262
+ self._model_kinds[application] = response.json()['data']['kind']
263
+
264
+ return self._model_kinds[application]
265
+
266
+ def submit(
267
+ self,
268
+ application: str,
269
+ arguments: AnyJSON,
270
+ *,
271
+ webhook_url: str | None = None,
272
+ ) -> SyncRequestController:
273
+ url = get_router_url(application, self._get_model_kind(application))
274
+
275
+ response = self._transport.request(
276
+ 'POST',
277
+ url,
278
+ json=get_arguments(application, arguments, webhook_url),
279
+ headers={'Idempotency-Key': str(uuid.uuid4())},
280
+ timeout=self.timeout,
281
+ )
282
+
283
+ request_id = response.json()['data']['request_id']
284
+
285
+ return self.get_request_controller(request_id)
286
+
287
+ def subscribe(
288
+ self,
289
+ application: str,
290
+ arguments: AnyJSON,
291
+ *,
292
+ on_enqueue: Optional[Callable[[str], None]] = None,
293
+ on_queue_update: Optional[Callable[[Status], None]] = None,
294
+ ) -> AnyJSON:
295
+ request_controller = self.submit(application=application, arguments=arguments)
296
+
297
+ if on_enqueue is not None:
298
+ on_enqueue(request_controller.request_id)
299
+
300
+ if on_queue_update is not None:
301
+ for status in request_controller.poll_request_status():
302
+ on_queue_update(status)
303
+
304
+ return request_controller.get()
305
+
306
+ def status(self, request_id: str) -> Status:
307
+ request_controller = self.get_request_controller(request_id)
308
+ return request_controller.status()
309
+
310
+ def result(self, request_id: str) -> AnyJSON:
311
+ request_controller = self.get_request_controller(request_id)
312
+ return request_controller.get()
313
+
314
+ def cancel(self, request_id: str) -> None:
315
+ request_controller = self.get_request_controller(request_id)
316
+ request_controller.cancel()
317
+
318
+ def _get_upload_url(self, content_type: str, size_bytes: int) -> Tuple[str, str, Dict[str, str]]:
319
+ response = self._transport.request(
320
+ 'POST',
321
+ UPLOAD_URL,
322
+ json={'content_type': content_type, 'size_bytes': size_bytes},
323
+ )
324
+ data = response.json()['data']
325
+
326
+ upload_headers = data.get('upload_headers') or {'Content-Type': content_type}
327
+ return data['public_url'], data['upload_url'], upload_headers
328
+
329
+ def upload(
330
+ self,
331
+ data: bytes | str,
332
+ content_type: str,
333
+ ) -> str:
334
+ data = self.ensure_bytes(data)
335
+
336
+ public_url, upload_url, upload_headers = self._get_upload_url(content_type, len(data))
337
+
338
+ response = self._upload_client.put(
339
+ upload_url,
340
+ content=data,
341
+ headers=upload_headers,
342
+ )
343
+ if response.is_error:
344
+ raise to_storage_error(response) from None
345
+
346
+ return public_url
347
+
348
+ def upload_file(self, path: os.PathLike) -> str:
349
+ mime_type = self.guess_mime_type(path)
350
+
351
+ with open(path, 'rb') as file:
352
+ return self.upload(file.read(), mime_type)
353
+
354
+ def upload_image(self, image: Image.Image, format: str = 'jpeg') -> str:
355
+ image_bytes = self.image_to_bytes(image, format)
356
+ return self.upload(image_bytes, f'image/{format}')
357
+
358
+
359
+ class AsyncClient(UploadMixin):
360
+ def __init__(
361
+ self,
362
+ base_url: str = BASE_URL,
363
+ timeout: float = DEFAULT_TIMEOUT,
364
+ api_key: str | None = None,
365
+ ):
366
+ self.base_url = base_url
367
+ self.timeout = timeout
368
+ self.api_key = api_key
369
+ self._model_kinds: Dict[str, str] = {}
370
+
371
+ @cached_per_loop
372
+ def _client(self) -> httpx.AsyncClient:
373
+ api_key = self.api_key or get_credential_key()
374
+
375
+ return httpx.AsyncClient(
376
+ headers={
377
+ 'Authorization': f'Bearer {api_key}',
378
+ 'Content-Type': 'application/json',
379
+ 'User-Agent': USER_AGENT,
380
+ },
381
+ timeout=self.timeout,
382
+ base_url=self.base_url,
383
+ )
384
+
385
+ @cached_per_loop
386
+ def _upload_client(self) -> httpx.AsyncClient:
387
+ return httpx.AsyncClient(timeout=self.timeout)
388
+
389
+ @cached_property
390
+ def _transport(self) -> AsyncHttpTransport:
391
+ retry_strategy = create_default_retry_strategy()
392
+ return AsyncHttpTransport(lambda: self._client, retry_strategy)
393
+
394
+ def get_request_controller(self, request_id: str) -> AsyncRequestController:
395
+ urls = RequestController.build_urls(self.base_url, request_id)
396
+ return AsyncRequestController(request_id, self._transport, **urls)
397
+
398
+ async def _get_model_kind(self, application: str) -> str:
399
+ if application not in self._model_kinds:
400
+ response = await self._transport.request('GET', get_catalog_url(application))
401
+ self._model_kinds[application] = response.json()['data']['kind']
402
+
403
+ return self._model_kinds[application]
404
+
405
+ async def submit(
406
+ self,
407
+ application: str,
408
+ arguments: AnyJSON,
409
+ *,
410
+ webhook_url: str | None = None,
411
+ ) -> AsyncRequestController:
412
+ url = get_router_url(application, await self._get_model_kind(application))
413
+
414
+ response = await self._transport.request(
415
+ 'POST',
416
+ url,
417
+ json=get_arguments(application, arguments, webhook_url),
418
+ headers={'Idempotency-Key': str(uuid.uuid4())},
419
+ timeout=self.timeout,
420
+ )
421
+
422
+ request_id = response.json()['data']['request_id']
423
+
424
+ return self.get_request_controller(request_id)
425
+
426
+ async def subscribe(
427
+ self,
428
+ application: str,
429
+ arguments: AnyJSON,
430
+ *,
431
+ on_enqueue: Optional[Callable[[str], None]] = None,
432
+ on_queue_update: Optional[Callable[[Status], None]] = None,
433
+ ) -> AnyJSON:
434
+ request_controller = await self.submit(application=application, arguments=arguments)
435
+
436
+ if on_enqueue is not None:
437
+ on_enqueue(request_controller.request_id)
438
+
439
+ if on_queue_update is not None:
440
+ async for status in request_controller.poll_request_status():
441
+ on_queue_update(status)
442
+
443
+ return await request_controller.get()
444
+
445
+ async def status(self, request_id: str) -> Status:
446
+ request_controller = self.get_request_controller(request_id)
447
+ return await request_controller.status()
448
+
449
+ async def result(self, request_id: str) -> AnyJSON:
450
+ request_controller = self.get_request_controller(request_id)
451
+ return await request_controller.get()
452
+
453
+ async def cancel(self, request_id: str) -> None:
454
+ request_controller = self.get_request_controller(request_id)
455
+ await request_controller.cancel()
456
+
457
+ async def _get_upload_url(self, content_type: str, size_bytes: int) -> Tuple[str, str, Dict[str, str]]:
458
+ response = await self._transport.request(
459
+ 'POST',
460
+ UPLOAD_URL,
461
+ json={'content_type': content_type, 'size_bytes': size_bytes},
462
+ )
463
+ data = response.json()['data']
464
+
465
+ upload_headers = data.get('upload_headers') or {'Content-Type': content_type}
466
+ return data['public_url'], data['upload_url'], upload_headers
467
+
468
+ async def upload(
469
+ self,
470
+ data: bytes | str,
471
+ content_type: str,
472
+ ) -> str:
473
+ data = self.ensure_bytes(data)
474
+
475
+ public_url, upload_url, upload_headers = await self._get_upload_url(content_type, len(data))
476
+
477
+ response = await self._upload_client.put(
478
+ upload_url,
479
+ content=data,
480
+ headers=upload_headers,
481
+ )
482
+ if response.is_error:
483
+ raise to_storage_error(response) from None
484
+
485
+ return public_url
486
+
487
+ async def upload_file(self, path: os.PathLike) -> str:
488
+ mime_type = self.guess_mime_type(path)
489
+
490
+ with open(path, 'rb') as file:
491
+ return await self.upload(file.read(), mime_type)
492
+
493
+ async def upload_image(self, image: Image.Image, format: str = 'jpeg') -> str:
494
+ image_bytes = self.image_to_bytes(image, format)
495
+ return await self.upload(image_bytes, f'image/{format}')
@@ -0,0 +1,91 @@
1
+ import re
2
+ from typing import Any, Dict, List
3
+
4
+ import httpx
5
+
6
+ from sume_client.exceptions import SumeClientError
7
+
8
+ STORAGE_ERROR_FIELD = re.compile(r'<(Code|Message)>([^<]{1,500})</\1>')
9
+
10
+
11
+ def read_json(response: httpx.Response) -> Any:
12
+ try:
13
+ return response.json()
14
+ except ValueError:
15
+ return None
16
+
17
+
18
+ def read_error(response: httpx.Response) -> Dict[str, Any]:
19
+ data = read_json(response)
20
+ error = data.get('error') if isinstance(data, dict) else None
21
+ return error if isinstance(error, dict) else {}
22
+
23
+
24
+ def field_errors(error: Dict[str, Any]) -> List[str]:
25
+ details = error.get('details')
26
+ items = details.get('errors') if isinstance(details, dict) else None
27
+
28
+ if not isinstance(items, list):
29
+ return []
30
+
31
+ messages = []
32
+ for item in items:
33
+ message = item.get('message') if isinstance(item, dict) else None
34
+ if not isinstance(message, str) or not message:
35
+ continue
36
+ path = item.get('path')
37
+ messages.append(f'{path}: {message}' if isinstance(path, str) and path else message)
38
+
39
+ return messages
40
+
41
+
42
+ def extract_error_message(response: httpx.Response) -> str:
43
+ data = read_json(response)
44
+ fallback = response.text or f'HTTP {response.status_code}'
45
+
46
+ if not isinstance(data, dict):
47
+ return fallback
48
+
49
+ error = data.get('error')
50
+ if isinstance(error, dict):
51
+ message = error.get('message') or error.get('code') or fallback
52
+ fields = field_errors(error)
53
+ return f"{message} {'; '.join(fields)}" if fields else message
54
+
55
+ for key in ('message', 'detail', 'error'):
56
+ value = data.get(key)
57
+ if isinstance(value, str) and value:
58
+ return value
59
+
60
+ return fallback
61
+
62
+
63
+ def to_client_error(response: httpx.Response) -> SumeClientError:
64
+ error = read_error(response)
65
+ return SumeClientError(
66
+ extract_error_message(response),
67
+ status_code=response.status_code,
68
+ code=error.get('code'),
69
+ request_id=error.get('request_id') or response.headers.get('x-sume-request-id'),
70
+ details=error.get('details'),
71
+ )
72
+
73
+
74
+ def raise_for_status(response: httpx.Response) -> None:
75
+ try:
76
+ response.raise_for_status()
77
+ except httpx.HTTPStatusError as exception:
78
+ raise to_client_error(response) from exception
79
+
80
+
81
+ def to_storage_error(response: httpx.Response) -> SumeClientError:
82
+ fields = dict(STORAGE_ERROR_FIELD.findall(response.text))
83
+ code = fields.get('Code')
84
+ message = f'Upload to storage failed with HTTP {response.status_code}'
85
+
86
+ if code:
87
+ message += f': {code}'
88
+ if fields.get('Message'):
89
+ message += f" ({fields['Message']})"
90
+
91
+ return SumeClientError(message, status_code=response.status_code, code=code)