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