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.
- sume_client/__init__.py +74 -0
- sume_client/auth.py +15 -0
- sume_client/exceptions.py +22 -0
- sume_client/http/__init__.py +0 -0
- sume_client/http/client.py +495 -0
- sume_client/http/error.py +91 -0
- sume_client/http/retry.py +85 -0
- sume_client/http/transport.py +129 -0
- sume_client/mixins/__init__.py +3 -0
- sume_client/mixins/upload.py +25 -0
- sume_client/types_.py +49 -0
- sume_client/utils.py +77 -0
- sume_client-0.1.0.dist-info/METADATA +529 -0
- sume_client-0.1.0.dist-info/RECORD +17 -0
- sume_client-0.1.0.dist-info/WHEEL +4 -0
- sume_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- sume_client-0.1.0.dist-info/licenses/NOTICE +17 -0
sume_client/__init__.py
ADDED
|
@@ -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)
|