nerdstack-ark 1.0.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.
- ark_py/__init__.py +31 -0
- ark_py/_shared.py +182 -0
- ark_py/async_client.py +543 -0
- ark_py/errors.py +76 -0
- ark_py/models.py +108 -0
- ark_py/py.typed +1 -0
- ark_py/s3.py +38 -0
- ark_py/sync.py +381 -0
- nerdstack_ark-1.0.0.dist-info/METADATA +187 -0
- nerdstack_ark-1.0.0.dist-info/RECORD +12 -0
- nerdstack_ark-1.0.0.dist-info/WHEEL +4 -0
- nerdstack_ark-1.0.0.dist-info/licenses/LICENSE +21 -0
ark_py/async_client.py
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import builtins
|
|
5
|
+
import mimetypes
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import AsyncIterable, AsyncIterator, Mapping
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
from typing import Any, BinaryIO, TypeVar, cast
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from ._shared import (
|
|
15
|
+
DEFAULT_BASE_URL,
|
|
16
|
+
DEFAULT_CONTENT_TYPE,
|
|
17
|
+
UploadSource,
|
|
18
|
+
api_url,
|
|
19
|
+
image_url,
|
|
20
|
+
parse_client_session,
|
|
21
|
+
query_string,
|
|
22
|
+
read_exact,
|
|
23
|
+
resolve_upload_source,
|
|
24
|
+
segment,
|
|
25
|
+
sorted_parts,
|
|
26
|
+
upload_payload,
|
|
27
|
+
validate_size,
|
|
28
|
+
)
|
|
29
|
+
from .errors import ArkError, error_from_response, invalid_argument, network_error, upload_error
|
|
30
|
+
from .models import ArkFile, ArkFolder, ArkUsage, ClientSession, FilePage, ImageOptions
|
|
31
|
+
|
|
32
|
+
T = TypeVar("T")
|
|
33
|
+
AsyncSource = str | os.PathLike[str] | BinaryIO | AsyncIterable[bytes]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AsyncArk:
|
|
37
|
+
"""Asynchronous Ark client for FastAPI, Starlette, aiohttp, and async workers."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
token: str,
|
|
42
|
+
*,
|
|
43
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
44
|
+
version: str = "v2",
|
|
45
|
+
timeout: float | httpx.Timeout = 30.0,
|
|
46
|
+
client: httpx.AsyncClient | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
if not token:
|
|
49
|
+
raise ArkError("UNAUTHORIZED", "An Ark API token is required")
|
|
50
|
+
self._token = token
|
|
51
|
+
self._base_url = base_url.rstrip("/")
|
|
52
|
+
self._version = version
|
|
53
|
+
self._client = client or httpx.AsyncClient(timeout=timeout, follow_redirects=True)
|
|
54
|
+
self._owns_client = client is None
|
|
55
|
+
self.files = AsyncFiles(self)
|
|
56
|
+
self.folders = AsyncFolders(self)
|
|
57
|
+
self.images = AsyncImages(self)
|
|
58
|
+
self.imports = AsyncImports(self)
|
|
59
|
+
|
|
60
|
+
async def __aenter__(self) -> AsyncArk:
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
async def __aexit__(
|
|
64
|
+
self,
|
|
65
|
+
exc_type: type[BaseException] | None,
|
|
66
|
+
exc: BaseException | None,
|
|
67
|
+
traceback: TracebackType | None,
|
|
68
|
+
) -> None:
|
|
69
|
+
await self.aclose()
|
|
70
|
+
|
|
71
|
+
async def aclose(self) -> None:
|
|
72
|
+
if self._owns_client:
|
|
73
|
+
await self._client.aclose()
|
|
74
|
+
|
|
75
|
+
async def usage(self) -> ArkUsage:
|
|
76
|
+
return ArkUsage.from_dict(await self._request("GET", "/usage"))
|
|
77
|
+
|
|
78
|
+
async def create_client_session(
|
|
79
|
+
self,
|
|
80
|
+
*,
|
|
81
|
+
scopes: list[str] | None = None,
|
|
82
|
+
folder_id: str | None = None,
|
|
83
|
+
ttl_seconds: int | None = None,
|
|
84
|
+
) -> ClientSession:
|
|
85
|
+
payload: dict[str, Any] = {}
|
|
86
|
+
if scopes is not None:
|
|
87
|
+
payload["scopes"] = scopes
|
|
88
|
+
if folder_id is not None:
|
|
89
|
+
payload["folderId"] = folder_id
|
|
90
|
+
if ttl_seconds is not None:
|
|
91
|
+
payload["ttlSeconds"] = ttl_seconds
|
|
92
|
+
return parse_client_session(await self._request("POST", "/client-sessions", json=payload))
|
|
93
|
+
|
|
94
|
+
def _url(self, path: str) -> str:
|
|
95
|
+
return api_url(self._base_url, self._version, path)
|
|
96
|
+
|
|
97
|
+
async def _request(
|
|
98
|
+
self,
|
|
99
|
+
method: str,
|
|
100
|
+
path: str,
|
|
101
|
+
*,
|
|
102
|
+
json: Mapping[str, Any] | None = None,
|
|
103
|
+
) -> dict[str, Any]:
|
|
104
|
+
try:
|
|
105
|
+
response = await self._client.request(
|
|
106
|
+
method,
|
|
107
|
+
self._url(path),
|
|
108
|
+
headers={"authorization": f"Bearer {self._token}"},
|
|
109
|
+
json=dict(json) if json is not None else None,
|
|
110
|
+
)
|
|
111
|
+
except httpx.HTTPError as error:
|
|
112
|
+
raise network_error(error) from error
|
|
113
|
+
if response.is_error:
|
|
114
|
+
raise error_from_response(response)
|
|
115
|
+
if response.status_code == 204:
|
|
116
|
+
return {}
|
|
117
|
+
value = response.json()
|
|
118
|
+
if not isinstance(value, dict):
|
|
119
|
+
raise ArkError("INTERNAL_ERROR", "Ark returned an invalid JSON response")
|
|
120
|
+
return cast(dict[str, Any], value)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class AsyncFiles:
|
|
124
|
+
def __init__(self, ark: AsyncArk) -> None:
|
|
125
|
+
self._ark = ark
|
|
126
|
+
|
|
127
|
+
async def list(
|
|
128
|
+
self,
|
|
129
|
+
*,
|
|
130
|
+
folder_id: str | None = None,
|
|
131
|
+
limit: int | None = None,
|
|
132
|
+
cursor: str | None = None,
|
|
133
|
+
) -> FilePage:
|
|
134
|
+
suffix = query_string({"folderId": folder_id, "limit": limit, "cursor": cursor})
|
|
135
|
+
value = await self._ark._request("GET", f"/files{suffix}")
|
|
136
|
+
raw_data = value.get("data")
|
|
137
|
+
data = tuple(
|
|
138
|
+
ArkFile.from_dict(item)
|
|
139
|
+
for item in (raw_data if isinstance(raw_data, list) else [])
|
|
140
|
+
if isinstance(item, Mapping)
|
|
141
|
+
)
|
|
142
|
+
next_cursor = value.get("nextCursor")
|
|
143
|
+
return FilePage(data, next_cursor if isinstance(next_cursor, str) else None)
|
|
144
|
+
|
|
145
|
+
async def get(self, file_id: str) -> ArkFile:
|
|
146
|
+
return ArkFile.from_dict(await self._ark._request("GET", f"/files/{segment(file_id)}"))
|
|
147
|
+
|
|
148
|
+
async def delete(self, file_id: str) -> bool:
|
|
149
|
+
value = await self._ark._request("DELETE", f"/files/{segment(file_id)}")
|
|
150
|
+
return bool(value.get("deleted"))
|
|
151
|
+
|
|
152
|
+
async def move(self, file_id: str, folder_id: str | None) -> ArkFile:
|
|
153
|
+
value = await self._ark._request(
|
|
154
|
+
"PATCH",
|
|
155
|
+
f"/files/{segment(file_id)}",
|
|
156
|
+
json={"folderId": folder_id},
|
|
157
|
+
)
|
|
158
|
+
return ArkFile.from_dict(value)
|
|
159
|
+
|
|
160
|
+
async def get_download_url(
|
|
161
|
+
self,
|
|
162
|
+
file_id: str,
|
|
163
|
+
*,
|
|
164
|
+
expires_in_seconds: int | None = None,
|
|
165
|
+
) -> str:
|
|
166
|
+
payload: dict[str, Any] = {"fileId": file_id}
|
|
167
|
+
if expires_in_seconds is not None:
|
|
168
|
+
payload["expiresInSeconds"] = expires_in_seconds
|
|
169
|
+
value = await self._ark._request("POST", "/downloads/presign", json=payload)
|
|
170
|
+
return str(value["url"])
|
|
171
|
+
|
|
172
|
+
async def upload(
|
|
173
|
+
self,
|
|
174
|
+
source: AsyncSource,
|
|
175
|
+
*,
|
|
176
|
+
size: int | None = None,
|
|
177
|
+
filename: str | None = None,
|
|
178
|
+
content_type: str | None = None,
|
|
179
|
+
folder_id: str | None = None,
|
|
180
|
+
metadata: Mapping[str, Any] | None = None,
|
|
181
|
+
) -> ArkFile:
|
|
182
|
+
async_stream = _as_async_iterable(source)
|
|
183
|
+
if async_stream is not None:
|
|
184
|
+
if size is None:
|
|
185
|
+
raise invalid_argument("size is required for async stream uploads")
|
|
186
|
+
validate_size(size)
|
|
187
|
+
if not filename:
|
|
188
|
+
raise invalid_argument("filename is required for async stream uploads")
|
|
189
|
+
resolved: UploadSource | None = None
|
|
190
|
+
resolved_size = size
|
|
191
|
+
resolved_filename = filename
|
|
192
|
+
resolved_type = (
|
|
193
|
+
content_type or mimetypes.guess_type(filename)[0] or DEFAULT_CONTENT_TYPE
|
|
194
|
+
)
|
|
195
|
+
else:
|
|
196
|
+
resolved = await asyncio.to_thread(
|
|
197
|
+
resolve_upload_source,
|
|
198
|
+
cast(str | os.PathLike[str] | BinaryIO, source),
|
|
199
|
+
size=size,
|
|
200
|
+
filename=filename,
|
|
201
|
+
content_type=content_type,
|
|
202
|
+
)
|
|
203
|
+
resolved_size = resolved.size
|
|
204
|
+
resolved_filename = resolved.filename
|
|
205
|
+
resolved_type = resolved.content_type
|
|
206
|
+
|
|
207
|
+
session = await self._ark._request(
|
|
208
|
+
"POST",
|
|
209
|
+
"/uploads/presign",
|
|
210
|
+
json=upload_payload(
|
|
211
|
+
resolved_filename,
|
|
212
|
+
resolved_size,
|
|
213
|
+
resolved_type,
|
|
214
|
+
folder_id,
|
|
215
|
+
metadata,
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
upload_id = str(session["uploadId"])
|
|
219
|
+
try:
|
|
220
|
+
parts = await self._upload_transfer(
|
|
221
|
+
resolved,
|
|
222
|
+
async_stream,
|
|
223
|
+
resolved_size,
|
|
224
|
+
resolved_type,
|
|
225
|
+
session,
|
|
226
|
+
)
|
|
227
|
+
completion: dict[str, Any] = {}
|
|
228
|
+
if parts is not None:
|
|
229
|
+
completion["parts"] = parts
|
|
230
|
+
value = await self._ark._request(
|
|
231
|
+
"POST",
|
|
232
|
+
f"/uploads/{segment(upload_id)}/complete",
|
|
233
|
+
json=completion,
|
|
234
|
+
)
|
|
235
|
+
return ArkFile.from_dict(value)
|
|
236
|
+
except BaseException:
|
|
237
|
+
with suppress(ArkError):
|
|
238
|
+
await self._ark._request(
|
|
239
|
+
"POST",
|
|
240
|
+
f"/uploads/{segment(upload_id)}/abort",
|
|
241
|
+
json={},
|
|
242
|
+
)
|
|
243
|
+
if async_stream is not None:
|
|
244
|
+
await _close_async_iterator(async_stream)
|
|
245
|
+
raise
|
|
246
|
+
|
|
247
|
+
async def _upload_transfer(
|
|
248
|
+
self,
|
|
249
|
+
source: UploadSource | None,
|
|
250
|
+
async_stream: AsyncIterator[bytes] | None,
|
|
251
|
+
size: int,
|
|
252
|
+
content_type: str,
|
|
253
|
+
session: Mapping[str, Any],
|
|
254
|
+
) -> builtins.list[dict[str, object]] | None:
|
|
255
|
+
if not session.get("multipart"):
|
|
256
|
+
session_headers = session.get("headers")
|
|
257
|
+
headers = {
|
|
258
|
+
"content-type": content_type,
|
|
259
|
+
"content-length": str(size),
|
|
260
|
+
**(
|
|
261
|
+
{str(key): str(value) for key, value in session_headers.items()}
|
|
262
|
+
if isinstance(session_headers, Mapping)
|
|
263
|
+
else {}
|
|
264
|
+
),
|
|
265
|
+
}
|
|
266
|
+
content = _iter_resolved(source, async_stream, size)
|
|
267
|
+
await self._put(str(session["url"]), content, headers=headers)
|
|
268
|
+
return None
|
|
269
|
+
return await self._upload_multipart(source, async_stream, size, session)
|
|
270
|
+
|
|
271
|
+
async def _upload_multipart(
|
|
272
|
+
self,
|
|
273
|
+
source: UploadSource | None,
|
|
274
|
+
async_stream: AsyncIterator[bytes] | None,
|
|
275
|
+
size: int,
|
|
276
|
+
session: Mapping[str, Any],
|
|
277
|
+
) -> builtins.list[dict[str, object]]:
|
|
278
|
+
raw_parts = session.get("parts")
|
|
279
|
+
if not isinstance(raw_parts, list) or not raw_parts:
|
|
280
|
+
raise ArkError("INTERNAL_ERROR", "Ark returned an invalid multipart session")
|
|
281
|
+
part_size = int(session["partSize"])
|
|
282
|
+
concurrency = max(1, min(int(session.get("maxConcurrency") or 4), len(raw_parts)))
|
|
283
|
+
semaphore = asyncio.Semaphore(concurrency)
|
|
284
|
+
reader = AsyncChunkReader(async_stream, size) if async_stream is not None else None
|
|
285
|
+
|
|
286
|
+
async def upload_part(
|
|
287
|
+
raw_part: Mapping[str, Any],
|
|
288
|
+
content: Any,
|
|
289
|
+
expected: int,
|
|
290
|
+
) -> dict[str, object]:
|
|
291
|
+
async with semaphore:
|
|
292
|
+
part_number = int(raw_part["partNumber"])
|
|
293
|
+
response = await self._put(
|
|
294
|
+
str(raw_part["url"]),
|
|
295
|
+
content,
|
|
296
|
+
headers={"content-length": str(expected)},
|
|
297
|
+
part_number=part_number,
|
|
298
|
+
)
|
|
299
|
+
etag = response.headers.get("etag", "").replace('"', "")
|
|
300
|
+
if not etag:
|
|
301
|
+
raise ArkError("UPLOAD_FAILED", f"Part {part_number} did not return an ETag")
|
|
302
|
+
return {"partNumber": part_number, "etag": etag}
|
|
303
|
+
|
|
304
|
+
tasks: list[asyncio.Task[dict[str, object]]] = []
|
|
305
|
+
try:
|
|
306
|
+
for raw_part in raw_parts:
|
|
307
|
+
if not isinstance(raw_part, Mapping):
|
|
308
|
+
raise ArkError("INTERNAL_ERROR", "Ark returned an invalid multipart part")
|
|
309
|
+
while len([task for task in tasks if not task.done()]) >= concurrency:
|
|
310
|
+
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
311
|
+
for task in done:
|
|
312
|
+
task.result()
|
|
313
|
+
part_number = int(raw_part["partNumber"])
|
|
314
|
+
start = (part_number - 1) * part_size
|
|
315
|
+
expected = min(part_size, size - start)
|
|
316
|
+
if expected <= 0:
|
|
317
|
+
raise ArkError("INTERNAL_ERROR", "Multipart session exceeds upload size")
|
|
318
|
+
if reader is not None:
|
|
319
|
+
content: Any = await reader.read_exact(expected)
|
|
320
|
+
elif source is not None and source.stream is not None:
|
|
321
|
+
content = await asyncio.to_thread(read_exact, source.stream, expected)
|
|
322
|
+
else:
|
|
323
|
+
content = _iter_resolved_range(cast(UploadSource, source), start, expected)
|
|
324
|
+
tasks.append(asyncio.create_task(upload_part(raw_part, content, expected)))
|
|
325
|
+
if reader is not None:
|
|
326
|
+
await reader.assert_complete()
|
|
327
|
+
elif source is not None and source.stream is not None:
|
|
328
|
+
overflow = await asyncio.to_thread(source.stream.read, 1)
|
|
329
|
+
if overflow:
|
|
330
|
+
raise invalid_argument(f"upload stream produced more than {source.size} bytes")
|
|
331
|
+
return sorted_parts(await asyncio.gather(*tasks))
|
|
332
|
+
except BaseException:
|
|
333
|
+
for task in tasks:
|
|
334
|
+
task.cancel()
|
|
335
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
336
|
+
if reader is not None:
|
|
337
|
+
await reader.aclose()
|
|
338
|
+
raise
|
|
339
|
+
|
|
340
|
+
async def _put(
|
|
341
|
+
self,
|
|
342
|
+
url: str,
|
|
343
|
+
content: Any,
|
|
344
|
+
*,
|
|
345
|
+
headers: Mapping[str, str],
|
|
346
|
+
part_number: int | None = None,
|
|
347
|
+
) -> httpx.Response:
|
|
348
|
+
try:
|
|
349
|
+
response = await self._ark._client.put(url, content=content, headers=headers)
|
|
350
|
+
except httpx.HTTPError as error:
|
|
351
|
+
raise network_error(error) from error
|
|
352
|
+
if response.is_error:
|
|
353
|
+
raise upload_error(response.status_code, part_number=part_number)
|
|
354
|
+
return response
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class AsyncFolders:
|
|
358
|
+
def __init__(self, ark: AsyncArk) -> None:
|
|
359
|
+
self._ark = ark
|
|
360
|
+
|
|
361
|
+
async def list(self, *, parent_id: str | None = None) -> tuple[ArkFolder, ...]:
|
|
362
|
+
value = await self._ark._request(
|
|
363
|
+
"GET",
|
|
364
|
+
f"/folders{query_string({'parentId': parent_id})}",
|
|
365
|
+
)
|
|
366
|
+
raw_data = value.get("data")
|
|
367
|
+
return tuple(
|
|
368
|
+
ArkFolder.from_dict(item)
|
|
369
|
+
for item in (raw_data if isinstance(raw_data, list) else [])
|
|
370
|
+
if isinstance(item, Mapping)
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
async def create(self, name: str, *, parent_id: str | None = None) -> ArkFolder:
|
|
374
|
+
payload: dict[str, Any] = {"name": name}
|
|
375
|
+
if parent_id is not None:
|
|
376
|
+
payload["parentId"] = parent_id
|
|
377
|
+
return ArkFolder.from_dict(await self._ark._request("POST", "/folders", json=payload))
|
|
378
|
+
|
|
379
|
+
async def rename(self, folder_id: str, name: str) -> ArkFolder:
|
|
380
|
+
return ArkFolder.from_dict(
|
|
381
|
+
await self._ark._request(
|
|
382
|
+
"PATCH",
|
|
383
|
+
f"/folders/{segment(folder_id)}",
|
|
384
|
+
json={"name": name},
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class AsyncImages:
|
|
390
|
+
def __init__(self, ark: AsyncArk) -> None:
|
|
391
|
+
self._ark = ark
|
|
392
|
+
|
|
393
|
+
def url(self, asset_id: str, options: ImageOptions | None = None) -> str:
|
|
394
|
+
return image_url(
|
|
395
|
+
self._ark._base_url,
|
|
396
|
+
self._ark._version,
|
|
397
|
+
asset_id,
|
|
398
|
+
options or ImageOptions(),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
async def signed_url(self, asset_id: str, *, expires_in_seconds: int | None = None) -> str:
|
|
402
|
+
suffix = query_string({"ttl": expires_in_seconds})
|
|
403
|
+
value = await self._ark._request(
|
|
404
|
+
"GET",
|
|
405
|
+
f"/assets/{segment(asset_id)}/signed-url{suffix}",
|
|
406
|
+
)
|
|
407
|
+
return str(value["url"])
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class AsyncImports:
|
|
411
|
+
def __init__(self, ark: AsyncArk) -> None:
|
|
412
|
+
self._ark = ark
|
|
413
|
+
|
|
414
|
+
async def create(self, input: Mapping[str, Any]) -> dict[str, Any]:
|
|
415
|
+
return await self._ark._request("POST", "/imports", json=input)
|
|
416
|
+
|
|
417
|
+
async def get(self, import_id: str) -> dict[str, Any]:
|
|
418
|
+
return await self._ark._request("GET", f"/imports/{segment(import_id)}")
|
|
419
|
+
|
|
420
|
+
async def cancel(self, import_id: str) -> bool:
|
|
421
|
+
value = await self._ark._request(
|
|
422
|
+
"POST",
|
|
423
|
+
f"/imports/{segment(import_id)}/cancel",
|
|
424
|
+
json={},
|
|
425
|
+
)
|
|
426
|
+
return bool(value.get("cancelled"))
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _as_async_iterable(source: AsyncSource) -> AsyncIterator[bytes] | None:
|
|
430
|
+
method = getattr(source, "__aiter__", None)
|
|
431
|
+
if method is None:
|
|
432
|
+
return None
|
|
433
|
+
return cast(AsyncIterable[bytes], source).__aiter__()
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
async def _close_async_iterator(source: AsyncIterator[bytes]) -> None:
|
|
437
|
+
close = getattr(source, "aclose", None)
|
|
438
|
+
if close is not None:
|
|
439
|
+
await close()
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
async def _iter_resolved(
|
|
443
|
+
source: UploadSource | None,
|
|
444
|
+
async_stream: AsyncIterator[bytes] | None,
|
|
445
|
+
size: int,
|
|
446
|
+
) -> AsyncIterator[bytes]:
|
|
447
|
+
if async_stream is not None:
|
|
448
|
+
reader = AsyncChunkReader(async_stream, size)
|
|
449
|
+
try:
|
|
450
|
+
while reader.consumed < size:
|
|
451
|
+
yield await reader.read_exact(min(64 * 1024, size - reader.consumed))
|
|
452
|
+
await reader.assert_complete()
|
|
453
|
+
finally:
|
|
454
|
+
await reader.aclose()
|
|
455
|
+
return
|
|
456
|
+
assert source is not None
|
|
457
|
+
async for chunk in _iter_resolved_range(source, 0, size):
|
|
458
|
+
yield chunk
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
async def _iter_resolved_range(source: UploadSource, start: int, size: int) -> AsyncIterator[bytes]:
|
|
462
|
+
if source.path is not None:
|
|
463
|
+
file_stream = await asyncio.to_thread(source.path.open, "rb")
|
|
464
|
+
try:
|
|
465
|
+
await asyncio.to_thread(file_stream.seek, start)
|
|
466
|
+
remaining = size
|
|
467
|
+
while remaining:
|
|
468
|
+
chunk = await asyncio.to_thread(file_stream.read, min(64 * 1024, remaining))
|
|
469
|
+
if not chunk:
|
|
470
|
+
raise invalid_argument("file ended before the expected upload range")
|
|
471
|
+
remaining -= len(chunk)
|
|
472
|
+
yield chunk
|
|
473
|
+
finally:
|
|
474
|
+
await asyncio.to_thread(file_stream.close)
|
|
475
|
+
return
|
|
476
|
+
binary_stream = cast(BinaryIO, source.stream)
|
|
477
|
+
remaining = size
|
|
478
|
+
while remaining:
|
|
479
|
+
chunk = await asyncio.to_thread(binary_stream.read, min(64 * 1024, remaining))
|
|
480
|
+
if not chunk:
|
|
481
|
+
raise invalid_argument(f"upload stream ended early; expected {source.size} bytes")
|
|
482
|
+
remaining -= len(chunk)
|
|
483
|
+
yield chunk
|
|
484
|
+
overflow = (
|
|
485
|
+
await asyncio.to_thread(binary_stream.read, 1) if start + size == source.size else b""
|
|
486
|
+
)
|
|
487
|
+
if overflow:
|
|
488
|
+
raise invalid_argument(f"upload stream produced more than {source.size} bytes")
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
class AsyncChunkReader:
|
|
492
|
+
def __init__(self, source: AsyncIterator[bytes], declared_size: int) -> None:
|
|
493
|
+
self._source = source
|
|
494
|
+
self._declared_size = declared_size
|
|
495
|
+
self._pending = b""
|
|
496
|
+
self._done = False
|
|
497
|
+
self.consumed = 0
|
|
498
|
+
|
|
499
|
+
async def read_exact(self, size: int) -> bytes:
|
|
500
|
+
output = bytearray()
|
|
501
|
+
while len(output) < size:
|
|
502
|
+
if not self._pending:
|
|
503
|
+
try:
|
|
504
|
+
chunk = await self._source.__anext__()
|
|
505
|
+
except StopAsyncIteration:
|
|
506
|
+
self._done = True
|
|
507
|
+
raise invalid_argument(
|
|
508
|
+
f"upload stream ended after {self.consumed + len(output)} bytes; "
|
|
509
|
+
f"expected {self._declared_size}"
|
|
510
|
+
) from None
|
|
511
|
+
if not isinstance(chunk, bytes):
|
|
512
|
+
raise invalid_argument("async upload streams must yield bytes")
|
|
513
|
+
self._pending = chunk
|
|
514
|
+
if not self._pending:
|
|
515
|
+
continue
|
|
516
|
+
needed = size - len(output)
|
|
517
|
+
output.extend(self._pending[:needed])
|
|
518
|
+
self._pending = self._pending[needed:]
|
|
519
|
+
self.consumed += size
|
|
520
|
+
return bytes(output)
|
|
521
|
+
|
|
522
|
+
async def assert_complete(self) -> None:
|
|
523
|
+
if self.consumed != self._declared_size:
|
|
524
|
+
raise invalid_argument(
|
|
525
|
+
f"upload stream produced {self.consumed} bytes; expected {self._declared_size}"
|
|
526
|
+
)
|
|
527
|
+
if self._pending:
|
|
528
|
+
raise invalid_argument(f"upload stream produced more than {self._declared_size} bytes")
|
|
529
|
+
if not self._done:
|
|
530
|
+
try:
|
|
531
|
+
chunk = await self._source.__anext__()
|
|
532
|
+
except StopAsyncIteration:
|
|
533
|
+
self._done = True
|
|
534
|
+
return
|
|
535
|
+
if chunk:
|
|
536
|
+
raise invalid_argument(
|
|
537
|
+
f"upload stream produced more than {self._declared_size} bytes"
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
async def aclose(self) -> None:
|
|
541
|
+
close = getattr(self._source, "aclose", None)
|
|
542
|
+
if close is not None:
|
|
543
|
+
await close()
|
ark_py/errors.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ArkError(Exception):
|
|
9
|
+
"""A normalized Ark REST or upload error."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
code: str,
|
|
14
|
+
message: str,
|
|
15
|
+
*,
|
|
16
|
+
status: int | None = None,
|
|
17
|
+
request_id: str | None = None,
|
|
18
|
+
details: dict[str, Any] | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.code = code
|
|
22
|
+
self.status = status
|
|
23
|
+
self.request_id = request_id
|
|
24
|
+
self.details = details
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def retryable(self) -> bool:
|
|
28
|
+
return self.code in {"NETWORK_ERROR", "RATE_LIMITED"} or (
|
|
29
|
+
self.status is not None and self.status >= 500
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def error_from_response(response: httpx.Response) -> ArkError:
|
|
34
|
+
try:
|
|
35
|
+
body = response.json()
|
|
36
|
+
except ValueError:
|
|
37
|
+
body = {}
|
|
38
|
+
envelope = body.get("error", {}) if isinstance(body, dict) else {}
|
|
39
|
+
if not isinstance(envelope, dict):
|
|
40
|
+
envelope = {}
|
|
41
|
+
return ArkError(
|
|
42
|
+
str(envelope.get("code") or "INTERNAL_ERROR"),
|
|
43
|
+
str(envelope.get("message") or f"Request failed with status {response.status_code}"),
|
|
44
|
+
status=response.status_code,
|
|
45
|
+
request_id=_optional_string(envelope.get("requestId")),
|
|
46
|
+
details=envelope.get("details") if isinstance(envelope.get("details"), dict) else None,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def network_error(error: httpx.HTTPError) -> ArkError:
|
|
51
|
+
return ArkError("NETWORK_ERROR", str(error) or "Network request failed")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def upload_error(status: int, *, part_number: int | None = None) -> ArkError:
|
|
55
|
+
if status in {401, 403}:
|
|
56
|
+
return ArkError(
|
|
57
|
+
"UPLOAD_EXPIRED",
|
|
58
|
+
"The upload authorization expired before the transfer finished. Please retry.",
|
|
59
|
+
status=status,
|
|
60
|
+
)
|
|
61
|
+
if status == 413:
|
|
62
|
+
return ArkError(
|
|
63
|
+
"FILE_TOO_LARGE",
|
|
64
|
+
"The file is larger than this upload allows.",
|
|
65
|
+
status=status,
|
|
66
|
+
)
|
|
67
|
+
label = f"part {part_number} " if part_number is not None else ""
|
|
68
|
+
return ArkError("UPLOAD_FAILED", f"Upload {label}failed with status {status}", status=status)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def invalid_argument(message: str) -> ArkError:
|
|
72
|
+
return ArkError("INVALID_ARGUMENT", message)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _optional_string(value: object) -> str | None:
|
|
76
|
+
return value if isinstance(value, str) else None
|