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