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/models.py ADDED
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from typing import Any, Literal
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class ArkFile:
10
+ id: str
11
+ name: str
12
+ original_name: str
13
+ size: int
14
+ mime_type: str
15
+ folder_id: str | None
16
+ status: str
17
+ checksum: str | None
18
+ url: str
19
+ created_at: str | None
20
+
21
+ @classmethod
22
+ def from_dict(cls, value: Mapping[str, Any]) -> ArkFile:
23
+ return cls(
24
+ id=str(value["id"]),
25
+ name=str(value["name"]),
26
+ original_name=str(value.get("originalName", value["name"])),
27
+ size=int(value["size"]),
28
+ mime_type=str(value.get("mimeType") or "application/octet-stream"),
29
+ folder_id=_optional_string(value.get("folderId")),
30
+ status=str(value.get("status") or "available"),
31
+ checksum=_optional_string(value.get("checksum")),
32
+ url=str(value.get("url") or ""),
33
+ created_at=_optional_string(value.get("createdAt")),
34
+ )
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class ArkFolder:
39
+ id: str
40
+ name: str
41
+ parent_id: str | None
42
+
43
+ @classmethod
44
+ def from_dict(cls, value: Mapping[str, Any]) -> ArkFolder:
45
+ return cls(
46
+ id=str(value["id"]),
47
+ name=str(value["name"]),
48
+ parent_id=_optional_string(value.get("parentId")),
49
+ )
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class StorageUsage:
54
+ used_bytes: int
55
+ pending_bytes: int
56
+ limit_bytes: int
57
+ available_bytes: int
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class ArkUsage:
62
+ storage: StorageUsage
63
+ tier: Literal["free", "paid"] | str
64
+ status: str
65
+
66
+ @classmethod
67
+ def from_dict(cls, value: Mapping[str, Any]) -> ArkUsage:
68
+ storage = value.get("storage")
69
+ if not isinstance(storage, Mapping):
70
+ storage = {}
71
+ return cls(
72
+ storage=StorageUsage(
73
+ used_bytes=int(storage.get("usedBytes", 0)),
74
+ pending_bytes=int(storage.get("pendingBytes", 0)),
75
+ limit_bytes=int(storage.get("limitBytes", 0)),
76
+ available_bytes=int(storage.get("availableBytes", 0)),
77
+ ),
78
+ tier=str(value.get("tier") or "free"),
79
+ status=str(value.get("status") or "active"),
80
+ )
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class FilePage:
85
+ data: tuple[ArkFile, ...]
86
+ next_cursor: str | None
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class ClientSession:
91
+ token: str
92
+ expires_at: str
93
+ expires_in_seconds: int
94
+ scopes: tuple[str, ...]
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class ImageOptions:
99
+ width: int | None = None
100
+ height: int | None = None
101
+ quality: int | None = None
102
+ format: Literal["original", "jpeg", "png", "webp", "avif"] = "original"
103
+ thumbnail: bool = False
104
+ watermark: bool = False
105
+
106
+
107
+ def _optional_string(value: object) -> str | None:
108
+ return value if isinstance(value, str) else None
ark_py/py.typed ADDED
@@ -0,0 +1 @@
1
+ # PEP 561 marker
ark_py/s3.py ADDED
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def create_s3_client(
7
+ *,
8
+ access_key_id: str,
9
+ secret_access_key: str,
10
+ endpoint_url: str = "https://ark.nerdstackgrp.com/s3",
11
+ region_name: str = "auto",
12
+ **kwargs: Any,
13
+ ) -> Any:
14
+ """Create a path-style boto3 S3 client configured for Ark.
15
+
16
+ Install the optional dependency with ``pip install nerdstack-ark[s3]``.
17
+ Ark credentials authenticate only against Ark and are not provider keys.
18
+ """
19
+ try:
20
+ import boto3 # type: ignore[import-not-found]
21
+ from botocore.config import Config # type: ignore[import-not-found]
22
+ except ImportError as error:
23
+ raise ImportError(
24
+ "S3 support requires boto3; install it with 'pip install nerdstack-ark[s3]'"
25
+ ) from error
26
+
27
+ config = kwargs.pop("config", None)
28
+ if config is None:
29
+ config = Config(signature_version="s3v4", s3={"addressing_style": "path"})
30
+ return boto3.client(
31
+ "s3",
32
+ endpoint_url=endpoint_url,
33
+ region_name=region_name,
34
+ aws_access_key_id=access_key_id,
35
+ aws_secret_access_key=secret_access_key,
36
+ config=config,
37
+ **kwargs,
38
+ )
ark_py/sync.py ADDED
@@ -0,0 +1,381 @@
1
+ from __future__ import annotations
2
+
3
+ import builtins
4
+ from collections.abc import Mapping
5
+ from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
6
+ from contextlib import suppress
7
+ from pathlib import Path
8
+ from types import TracebackType
9
+ from typing import Any, BinaryIO, TypeVar, cast
10
+
11
+ import httpx
12
+
13
+ from ._shared import (
14
+ DEFAULT_BASE_URL,
15
+ UploadSource,
16
+ api_url,
17
+ ensure_stream_complete,
18
+ image_url,
19
+ iter_exact,
20
+ iter_file_range,
21
+ parse_client_session,
22
+ query_string,
23
+ read_exact,
24
+ resolve_upload_source,
25
+ segment,
26
+ sorted_parts,
27
+ upload_payload,
28
+ )
29
+ from .errors import ArkError, error_from_response, network_error, upload_error
30
+ from .models import ArkFile, ArkFolder, ArkUsage, ClientSession, FilePage, ImageOptions
31
+
32
+ T = TypeVar("T")
33
+
34
+
35
+ class Ark:
36
+ """Synchronous Ark client for Django, Flask, Celery, scripts, and workers."""
37
+
38
+ def __init__(
39
+ self,
40
+ token: str,
41
+ *,
42
+ base_url: str = DEFAULT_BASE_URL,
43
+ version: str = "v2",
44
+ timeout: float | httpx.Timeout = 30.0,
45
+ client: httpx.Client | None = None,
46
+ ) -> None:
47
+ if not token:
48
+ raise ArkError("UNAUTHORIZED", "An Ark API token is required")
49
+ self._token = token
50
+ self._base_url = base_url.rstrip("/")
51
+ self._version = version
52
+ self._client = client or httpx.Client(timeout=timeout, follow_redirects=True)
53
+ self._owns_client = client is None
54
+ self.files = Files(self)
55
+ self.folders = Folders(self)
56
+ self.images = Images(self)
57
+ self.imports = Imports(self)
58
+
59
+ def __enter__(self) -> Ark:
60
+ return self
61
+
62
+ def __exit__(
63
+ self,
64
+ exc_type: type[BaseException] | None,
65
+ exc: BaseException | None,
66
+ traceback: TracebackType | None,
67
+ ) -> None:
68
+ self.close()
69
+
70
+ def close(self) -> None:
71
+ if self._owns_client:
72
+ self._client.close()
73
+
74
+ def usage(self) -> ArkUsage:
75
+ return ArkUsage.from_dict(self._request("GET", "/usage"))
76
+
77
+ def create_client_session(
78
+ self,
79
+ *,
80
+ scopes: list[str] | None = None,
81
+ folder_id: str | None = None,
82
+ ttl_seconds: int | None = None,
83
+ ) -> ClientSession:
84
+ payload: dict[str, Any] = {}
85
+ if scopes is not None:
86
+ payload["scopes"] = scopes
87
+ if folder_id is not None:
88
+ payload["folderId"] = folder_id
89
+ if ttl_seconds is not None:
90
+ payload["ttlSeconds"] = ttl_seconds
91
+ return parse_client_session(self._request("POST", "/client-sessions", json=payload))
92
+
93
+ def _url(self, path: str) -> str:
94
+ return api_url(self._base_url, self._version, path)
95
+
96
+ def _request(
97
+ self,
98
+ method: str,
99
+ path: str,
100
+ *,
101
+ json: Mapping[str, Any] | None = None,
102
+ ) -> dict[str, Any]:
103
+ try:
104
+ response = self._client.request(
105
+ method,
106
+ self._url(path),
107
+ headers={"authorization": f"Bearer {self._token}"},
108
+ json=dict(json) if json is not None else None,
109
+ )
110
+ except httpx.HTTPError as error:
111
+ raise network_error(error) from error
112
+ if response.is_error:
113
+ raise error_from_response(response)
114
+ if response.status_code == 204:
115
+ return {}
116
+ value = response.json()
117
+ if not isinstance(value, dict):
118
+ raise ArkError("INTERNAL_ERROR", "Ark returned an invalid JSON response")
119
+ return cast(dict[str, Any], value)
120
+
121
+
122
+ class Files:
123
+ def __init__(self, ark: Ark) -> None:
124
+ self._ark = ark
125
+
126
+ def list(
127
+ self,
128
+ *,
129
+ folder_id: str | None = None,
130
+ limit: int | None = None,
131
+ cursor: str | None = None,
132
+ ) -> FilePage:
133
+ suffix = query_string({"folderId": folder_id, "limit": limit, "cursor": cursor})
134
+ value = self._ark._request("GET", f"/files{suffix}")
135
+ raw_data = value.get("data")
136
+ data = tuple(
137
+ ArkFile.from_dict(item)
138
+ for item in (raw_data if isinstance(raw_data, list) else [])
139
+ if isinstance(item, Mapping)
140
+ )
141
+ next_cursor = value.get("nextCursor")
142
+ return FilePage(data, next_cursor if isinstance(next_cursor, str) else None)
143
+
144
+ def get(self, file_id: str) -> ArkFile:
145
+ return ArkFile.from_dict(self._ark._request("GET", f"/files/{segment(file_id)}"))
146
+
147
+ def delete(self, file_id: str) -> bool:
148
+ value = self._ark._request("DELETE", f"/files/{segment(file_id)}")
149
+ return bool(value.get("deleted"))
150
+
151
+ def move(self, file_id: str, folder_id: str | None) -> ArkFile:
152
+ value = self._ark._request(
153
+ "PATCH",
154
+ f"/files/{segment(file_id)}",
155
+ json={"folderId": folder_id},
156
+ )
157
+ return ArkFile.from_dict(value)
158
+
159
+ def get_download_url(self, file_id: str, *, expires_in_seconds: int | None = None) -> str:
160
+ payload: dict[str, Any] = {"fileId": file_id}
161
+ if expires_in_seconds is not None:
162
+ payload["expiresInSeconds"] = expires_in_seconds
163
+ value = self._ark._request("POST", "/downloads/presign", json=payload)
164
+ return str(value["url"])
165
+
166
+ def upload(
167
+ self,
168
+ source: str | Path | BinaryIO,
169
+ *,
170
+ size: int | None = None,
171
+ filename: str | None = None,
172
+ content_type: str | None = None,
173
+ folder_id: str | None = None,
174
+ metadata: Mapping[str, Any] | None = None,
175
+ ) -> ArkFile:
176
+ resolved = resolve_upload_source(
177
+ source,
178
+ size=size,
179
+ filename=filename,
180
+ content_type=content_type,
181
+ )
182
+ session = self._ark._request(
183
+ "POST",
184
+ "/uploads/presign",
185
+ json=upload_payload(
186
+ resolved.filename,
187
+ resolved.size,
188
+ resolved.content_type,
189
+ folder_id,
190
+ metadata,
191
+ ),
192
+ )
193
+ upload_id = str(session["uploadId"])
194
+ try:
195
+ parts = self._upload_transfer(resolved, session)
196
+ completion: dict[str, Any] = {}
197
+ if parts is not None:
198
+ completion["parts"] = parts
199
+ value = self._ark._request(
200
+ "POST",
201
+ f"/uploads/{segment(upload_id)}/complete",
202
+ json=completion,
203
+ )
204
+ return ArkFile.from_dict(value)
205
+ except BaseException:
206
+ with suppress(ArkError):
207
+ self._ark._request("POST", f"/uploads/{segment(upload_id)}/abort", json={})
208
+ raise
209
+
210
+ def _upload_transfer(
211
+ self,
212
+ source: UploadSource,
213
+ session: Mapping[str, Any],
214
+ ) -> builtins.list[dict[str, object]] | None:
215
+ if not session.get("multipart"):
216
+ url = str(session["url"])
217
+ session_headers = session.get("headers")
218
+ headers = {
219
+ "content-type": source.content_type,
220
+ "content-length": str(source.size),
221
+ **(
222
+ {str(key): str(value) for key, value in session_headers.items()}
223
+ if isinstance(session_headers, Mapping)
224
+ else {}
225
+ ),
226
+ }
227
+ content = (
228
+ iter_file_range(source.path, 0, source.size)
229
+ if source.path is not None
230
+ else iter_exact(cast(BinaryIO, source.stream), source.size)
231
+ )
232
+ self._put(url, content, headers=headers)
233
+ return None
234
+ return self._upload_multipart(source, session)
235
+
236
+ def _upload_multipart(
237
+ self,
238
+ source: UploadSource,
239
+ session: Mapping[str, Any],
240
+ ) -> builtins.list[dict[str, object]]:
241
+ raw_parts = session.get("parts")
242
+ if not isinstance(raw_parts, list) or not raw_parts:
243
+ raise ArkError("INTERNAL_ERROR", "Ark returned an invalid multipart session")
244
+ part_size = int(session["partSize"])
245
+ concurrency = max(1, min(int(session.get("maxConcurrency") or 4), len(raw_parts)))
246
+ results: list[dict[str, object]] = []
247
+ pending: set[Future[dict[str, object]]] = set()
248
+
249
+ def collect(done: set[Future[dict[str, object]]]) -> None:
250
+ for future in done:
251
+ results.append(future.result())
252
+
253
+ with ThreadPoolExecutor(max_workers=concurrency, thread_name_prefix="ark-upload") as pool:
254
+ for raw_part in raw_parts:
255
+ if not isinstance(raw_part, Mapping):
256
+ raise ArkError("INTERNAL_ERROR", "Ark returned an invalid multipart part")
257
+ while len(pending) >= concurrency:
258
+ done, pending = wait(pending, return_when=FIRST_COMPLETED)
259
+ collect(done)
260
+ part_number = int(raw_part["partNumber"])
261
+ start = (part_number - 1) * part_size
262
+ expected = min(part_size, source.size - start)
263
+ if expected <= 0:
264
+ raise ArkError("INTERNAL_ERROR", "Multipart session exceeds upload size")
265
+ content: bytes | Any
266
+ if source.path is not None:
267
+ content = iter_file_range(source.path, start, expected)
268
+ else:
269
+ content = read_exact(cast(BinaryIO, source.stream), expected)
270
+ pending.add(
271
+ pool.submit(
272
+ self._put_part,
273
+ str(raw_part["url"]),
274
+ content,
275
+ expected,
276
+ part_number,
277
+ )
278
+ )
279
+ if source.stream is not None:
280
+ ensure_stream_complete(source.stream, source.size)
281
+ collect(pending)
282
+ return sorted_parts(results)
283
+
284
+ def _put_part(
285
+ self,
286
+ url: str,
287
+ content: Any,
288
+ size: int,
289
+ part_number: int,
290
+ ) -> dict[str, object]:
291
+ response = self._put(
292
+ url,
293
+ content,
294
+ headers={"content-length": str(size)},
295
+ part_number=part_number,
296
+ )
297
+ etag = response.headers.get("etag", "").replace('"', "")
298
+ if not etag:
299
+ raise ArkError("UPLOAD_FAILED", f"Part {part_number} did not return an ETag")
300
+ return {"partNumber": part_number, "etag": etag}
301
+
302
+ def _put(
303
+ self,
304
+ url: str,
305
+ content: Any,
306
+ *,
307
+ headers: Mapping[str, str],
308
+ part_number: int | None = None,
309
+ ) -> httpx.Response:
310
+ try:
311
+ response = self._ark._client.put(url, content=content, headers=headers)
312
+ except httpx.HTTPError as error:
313
+ raise network_error(error) from error
314
+ if response.is_error:
315
+ raise upload_error(response.status_code, part_number=part_number)
316
+ return response
317
+
318
+
319
+ class Folders:
320
+ def __init__(self, ark: Ark) -> None:
321
+ self._ark = ark
322
+
323
+ def list(self, *, parent_id: str | None = None) -> tuple[ArkFolder, ...]:
324
+ value = self._ark._request("GET", f"/folders{query_string({'parentId': parent_id})}")
325
+ raw_data = value.get("data")
326
+ return tuple(
327
+ ArkFolder.from_dict(item)
328
+ for item in (raw_data if isinstance(raw_data, list) else [])
329
+ if isinstance(item, Mapping)
330
+ )
331
+
332
+ def create(self, name: str, *, parent_id: str | None = None) -> ArkFolder:
333
+ payload: dict[str, Any] = {"name": name}
334
+ if parent_id is not None:
335
+ payload["parentId"] = parent_id
336
+ return ArkFolder.from_dict(self._ark._request("POST", "/folders", json=payload))
337
+
338
+ def rename(self, folder_id: str, name: str) -> ArkFolder:
339
+ return ArkFolder.from_dict(
340
+ self._ark._request(
341
+ "PATCH",
342
+ f"/folders/{segment(folder_id)}",
343
+ json={"name": name},
344
+ )
345
+ )
346
+
347
+
348
+ class Images:
349
+ def __init__(self, ark: Ark) -> None:
350
+ self._ark = ark
351
+
352
+ def url(self, asset_id: str, options: ImageOptions | None = None) -> str:
353
+ return image_url(
354
+ self._ark._base_url,
355
+ self._ark._version,
356
+ asset_id,
357
+ options or ImageOptions(),
358
+ )
359
+
360
+ def signed_url(self, asset_id: str, *, expires_in_seconds: int | None = None) -> str:
361
+ suffix = query_string({"ttl": expires_in_seconds})
362
+ value = self._ark._request(
363
+ "GET",
364
+ f"/assets/{segment(asset_id)}/signed-url{suffix}",
365
+ )
366
+ return str(value["url"])
367
+
368
+
369
+ class Imports:
370
+ def __init__(self, ark: Ark) -> None:
371
+ self._ark = ark
372
+
373
+ def create(self, input: Mapping[str, Any]) -> dict[str, Any]:
374
+ return self._ark._request("POST", "/imports", json=input)
375
+
376
+ def get(self, import_id: str) -> dict[str, Any]:
377
+ return self._ark._request("GET", f"/imports/{segment(import_id)}")
378
+
379
+ def cancel(self, import_id: str) -> bool:
380
+ value = self._ark._request("POST", f"/imports/{segment(import_id)}/cancel", json={})
381
+ return bool(value.get("cancelled"))