nerdstack-ark 1.0.1__tar.gz → 1.0.2__tar.gz
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.
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.2}/PKG-INFO +1 -1
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.2}/pyproject.toml +1 -1
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.2}/src/ark_py/__init__.py +1 -1
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.2}/src/ark_py/_shared.py +0 -39
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.2}/src/ark_py/async_client.py +11 -27
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.2}/src/ark_py/models.py +10 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.2}/src/ark_py/sync.py +11 -32
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.2}/tests/test_sync.py +0 -93
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.2}/.gitignore +0 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.2}/LICENSE +0 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.2}/README.md +0 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.2}/examples/django/apps.py +0 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.2}/examples/django/views.py +0 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.2}/examples/fastapi/app.py +0 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.2}/examples/flask/app.py +0 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.2}/src/ark_py/errors.py +0 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.2}/src/ark_py/py.typed +0 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.2}/src/ark_py/s3.py +0 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.2}/tests/conftest.py +0 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.2}/tests/test_async.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: nerdstack-ark
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.2
|
|
4
4
|
Summary: Official Python SDK for Ark storage, with sync, async, and S3-compatible access.
|
|
5
5
|
Project-URL: Homepage, https://ark.nerdstackgrp.com
|
|
6
6
|
Project-URL: Documentation, https://github.com/joshhumphrey02/ark-sdk/tree/master/packages/ark-py#readme
|
|
@@ -180,42 +180,3 @@ def sorted_parts(parts: Iterable[dict[str, object]]) -> list[dict[str, object]]:
|
|
|
180
180
|
return value
|
|
181
181
|
|
|
182
182
|
return sorted(parts, key=part_number)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def folders_payload(value: Mapping[str, Any]) -> list[Any]:
|
|
186
|
-
"""Read a folder list under either key Ark may return it as.
|
|
187
|
-
|
|
188
|
-
The public API documents `folders`, and that is the handler that actually
|
|
189
|
-
serves `/v2/folders`; the developer-API handler for the same path returns
|
|
190
|
-
`data`. Reading only `data` meant this call silently returned zero folders
|
|
191
|
-
against a perfectly valid response -- worse than an error, because nothing
|
|
192
|
-
surfaced. Both keys are accepted so the SDK works whichever answers.
|
|
193
|
-
"""
|
|
194
|
-
for key in ("folders", "data"):
|
|
195
|
-
candidate = value.get(key)
|
|
196
|
-
if isinstance(candidate, list):
|
|
197
|
-
return candidate
|
|
198
|
-
return []
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
MAX_REQUEST_ATTEMPTS = 3
|
|
202
|
-
RETRY_BASE_DELAY_SECONDS = 0.5
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
def is_retryable_status(status: int) -> bool:
|
|
206
|
-
"""Only these are worth retrying; auth and validation failures are not."""
|
|
207
|
-
return status == 429 or status >= 500
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def retry_delay_seconds(attempt: int) -> float:
|
|
211
|
-
"""Exponential backoff with full jitter.
|
|
212
|
-
|
|
213
|
-
Jitter matters: without it every client that failed at the same moment
|
|
214
|
-
retries at the same moment and re-creates the overload that caused the
|
|
215
|
-
failure. Mirrors the TypeScript client so both SDKs behave alike under
|
|
216
|
-
load.
|
|
217
|
-
"""
|
|
218
|
-
import random
|
|
219
|
-
|
|
220
|
-
ceiling = min(RETRY_BASE_DELAY_SECONDS * (2 ** (attempt - 1)), 30.0)
|
|
221
|
-
return random.random() * ceiling
|
|
@@ -15,11 +15,7 @@ from ._shared import (
|
|
|
15
15
|
DEFAULT_BASE_URL,
|
|
16
16
|
DEFAULT_CONTENT_TYPE,
|
|
17
17
|
UploadSource,
|
|
18
|
-
MAX_REQUEST_ATTEMPTS,
|
|
19
18
|
api_url,
|
|
20
|
-
folders_payload,
|
|
21
|
-
is_retryable_status,
|
|
22
|
-
retry_delay_seconds,
|
|
23
19
|
image_url,
|
|
24
20
|
parse_client_session,
|
|
25
21
|
query_string,
|
|
@@ -105,28 +101,15 @@ class AsyncArk:
|
|
|
105
101
|
*,
|
|
106
102
|
json: Mapping[str, Any] | None = None,
|
|
107
103
|
) -> dict[str, Any]:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
json=dict(json) if json is not None else None,
|
|
118
|
-
)
|
|
119
|
-
except httpx.HTTPError as error:
|
|
120
|
-
if attempt == MAX_REQUEST_ATTEMPTS:
|
|
121
|
-
raise network_error(error) from error
|
|
122
|
-
await asyncio.sleep(retry_delay_seconds(attempt))
|
|
123
|
-
continue
|
|
124
|
-
if not is_retryable_status(response.status_code):
|
|
125
|
-
break
|
|
126
|
-
if attempt == MAX_REQUEST_ATTEMPTS:
|
|
127
|
-
break
|
|
128
|
-
await asyncio.sleep(retry_delay_seconds(attempt))
|
|
129
|
-
assert response is not None
|
|
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
|
|
130
113
|
if response.is_error:
|
|
131
114
|
raise error_from_response(response)
|
|
132
115
|
if response.status_code == 204:
|
|
@@ -380,9 +363,10 @@ class AsyncFolders:
|
|
|
380
363
|
"GET",
|
|
381
364
|
f"/folders{query_string({'parentId': parent_id})}",
|
|
382
365
|
)
|
|
366
|
+
raw_data = value.get("data")
|
|
383
367
|
return tuple(
|
|
384
368
|
ArkFolder.from_dict(item)
|
|
385
|
-
for item in
|
|
369
|
+
for item in (raw_data if isinstance(raw_data, list) else [])
|
|
386
370
|
if isinstance(item, Mapping)
|
|
387
371
|
)
|
|
388
372
|
|
|
@@ -15,7 +15,15 @@ class ArkFile:
|
|
|
15
15
|
folder_id: str | None
|
|
16
16
|
status: str
|
|
17
17
|
checksum: str | None
|
|
18
|
+
#: Permanent, unsigned CDN delivery URL. Safe to store; it does not expire.
|
|
19
|
+
#: Use this value as-is -- never build a URL from the id or name, and never
|
|
20
|
+
#: append a query parameter to reach a variant. ``thumbnail_url`` is the
|
|
21
|
+
#: thumbnail.
|
|
18
22
|
url: str
|
|
23
|
+
#: Permanent CDN URL for the generated thumbnail, or None if there is none.
|
|
24
|
+
thumbnail_url: str | None
|
|
25
|
+
#: Permanent CDN URL for the compressed variant, or None if there is none.
|
|
26
|
+
compressed_url: str | None
|
|
19
27
|
created_at: str | None
|
|
20
28
|
|
|
21
29
|
@classmethod
|
|
@@ -30,6 +38,8 @@ class ArkFile:
|
|
|
30
38
|
status=str(value.get("status") or "available"),
|
|
31
39
|
checksum=_optional_string(value.get("checksum")),
|
|
32
40
|
url=str(value.get("url") or ""),
|
|
41
|
+
thumbnail_url=_optional_string(value.get("thumbnailUrl")),
|
|
42
|
+
compressed_url=_optional_string(value.get("compressedUrl")),
|
|
33
43
|
created_at=_optional_string(value.get("createdAt")),
|
|
34
44
|
)
|
|
35
45
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import builtins
|
|
4
|
-
import time
|
|
5
4
|
from collections.abc import Mapping
|
|
6
5
|
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
|
|
7
6
|
from contextlib import suppress
|
|
@@ -15,11 +14,7 @@ from ._shared import (
|
|
|
15
14
|
DEFAULT_BASE_URL,
|
|
16
15
|
UploadSource,
|
|
17
16
|
api_url,
|
|
18
|
-
MAX_REQUEST_ATTEMPTS,
|
|
19
17
|
ensure_stream_complete,
|
|
20
|
-
folders_payload,
|
|
21
|
-
is_retryable_status,
|
|
22
|
-
retry_delay_seconds,
|
|
23
18
|
image_url,
|
|
24
19
|
iter_exact,
|
|
25
20
|
iter_file_range,
|
|
@@ -105,32 +100,15 @@ class Ark:
|
|
|
105
100
|
*,
|
|
106
101
|
json: Mapping[str, Any] | None = None,
|
|
107
102
|
) -> dict[str, Any]:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
method,
|
|
118
|
-
self._url(path),
|
|
119
|
-
headers={"authorization": f"Bearer {self._token}"},
|
|
120
|
-
json=dict(json) if json is not None else None,
|
|
121
|
-
)
|
|
122
|
-
except httpx.HTTPError as error:
|
|
123
|
-
last_error = error
|
|
124
|
-
if attempt == MAX_REQUEST_ATTEMPTS:
|
|
125
|
-
raise network_error(error) from error
|
|
126
|
-
time.sleep(retry_delay_seconds(attempt))
|
|
127
|
-
continue
|
|
128
|
-
if not is_retryable_status(response.status_code):
|
|
129
|
-
break
|
|
130
|
-
if attempt == MAX_REQUEST_ATTEMPTS:
|
|
131
|
-
break
|
|
132
|
-
time.sleep(retry_delay_seconds(attempt))
|
|
133
|
-
assert response is not None
|
|
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
|
|
134
112
|
if response.is_error:
|
|
135
113
|
raise error_from_response(response)
|
|
136
114
|
if response.status_code == 204:
|
|
@@ -344,9 +322,10 @@ class Folders:
|
|
|
344
322
|
|
|
345
323
|
def list(self, *, parent_id: str | None = None) -> tuple[ArkFolder, ...]:
|
|
346
324
|
value = self._ark._request("GET", f"/folders{query_string({'parentId': parent_id})}")
|
|
325
|
+
raw_data = value.get("data")
|
|
347
326
|
return tuple(
|
|
348
327
|
ArkFolder.from_dict(item)
|
|
349
|
-
for item in
|
|
328
|
+
for item in (raw_data if isinstance(raw_data, list) else [])
|
|
350
329
|
if isinstance(item, Mapping)
|
|
351
330
|
)
|
|
352
331
|
|
|
@@ -237,96 +237,3 @@ def test_non_seekable_stream_requires_size_and_filename() -> None:
|
|
|
237
237
|
with pytest.raises(ArkError, match="filename is required"):
|
|
238
238
|
ark.files.upload(NonSeekable(b"data"), size=4)
|
|
239
239
|
ark._client.close()
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
def test_folders_list_reads_documented_folders_key() -> None:
|
|
243
|
-
"""The shape production returns.
|
|
244
|
-
|
|
245
|
-
Reading only `data` made this return an empty tuple against a perfectly
|
|
246
|
-
valid response -- silently, which is worse than raising: a caller sees an
|
|
247
|
-
account with no folders rather than an error they can act on.
|
|
248
|
-
"""
|
|
249
|
-
|
|
250
|
-
def handler(request: httpx.Request) -> httpx.Response:
|
|
251
|
-
return json_response(
|
|
252
|
-
{
|
|
253
|
-
"folders": [
|
|
254
|
-
{"id": "f1", "name": "Products", "parentId": None},
|
|
255
|
-
{"id": "f2", "name": "Docs", "parentId": None},
|
|
256
|
-
],
|
|
257
|
-
"pagination": {"page": 1, "limit": 50, "total": 2, "pages": 1},
|
|
258
|
-
}
|
|
259
|
-
)
|
|
260
|
-
|
|
261
|
-
ark = Ark("token", client=client_for(handler))
|
|
262
|
-
folders = ark.folders.list()
|
|
263
|
-
assert len(folders) == 2
|
|
264
|
-
assert folders[0].name == "Products"
|
|
265
|
-
assert folders[1].id == "f2"
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
def test_folders_list_still_reads_data_key() -> None:
|
|
269
|
-
"""The developer-API handler for the same path. Both must work."""
|
|
270
|
-
|
|
271
|
-
def handler(request: httpx.Request) -> httpx.Response:
|
|
272
|
-
return json_response({"data": [{"id": "f9", "name": "Only", "parentId": None}]})
|
|
273
|
-
|
|
274
|
-
ark = Ark("token", client=client_for(handler))
|
|
275
|
-
folders = ark.folders.list()
|
|
276
|
-
assert len(folders) == 1
|
|
277
|
-
assert folders[0].id == "f9"
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
def test_folders_list_tolerates_an_unexpected_shape() -> None:
|
|
281
|
-
def handler(request: httpx.Request) -> httpx.Response:
|
|
282
|
-
return json_response({"unexpected": True})
|
|
283
|
-
|
|
284
|
-
ark = Ark("token", client=client_for(handler))
|
|
285
|
-
assert ark.folders.list() == ()
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
def test_request_retries_a_transient_server_error() -> None:
|
|
289
|
-
"""A 500 is retried; the caller sees the eventual success.
|
|
290
|
-
|
|
291
|
-
A transient network failure on a multi-megabyte upload previously surfaced
|
|
292
|
-
as a bare NETWORK_ERROR that succeeded on the caller's own retry -- work
|
|
293
|
-
every caller had to reimplement, and which the TypeScript client already
|
|
294
|
-
did.
|
|
295
|
-
"""
|
|
296
|
-
attempts = {"n": 0}
|
|
297
|
-
|
|
298
|
-
def handler(request: httpx.Request) -> httpx.Response:
|
|
299
|
-
attempts["n"] += 1
|
|
300
|
-
if attempts["n"] < 3:
|
|
301
|
-
return json_response({"error": {"message": "boom"}}, 500)
|
|
302
|
-
return json_response(
|
|
303
|
-
{
|
|
304
|
-
"storage": {
|
|
305
|
-
"usedBytes": 1,
|
|
306
|
-
"pendingBytes": 0,
|
|
307
|
-
"limitBytes": 2,
|
|
308
|
-
"availableBytes": 1,
|
|
309
|
-
},
|
|
310
|
-
"tier": "free",
|
|
311
|
-
"status": "active",
|
|
312
|
-
}
|
|
313
|
-
)
|
|
314
|
-
|
|
315
|
-
ark = Ark("token", client=client_for(handler))
|
|
316
|
-
usage = ark.usage()
|
|
317
|
-
assert attempts["n"] == 3
|
|
318
|
-
assert usage.storage.used_bytes == 1
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
def test_request_does_not_retry_a_client_error() -> None:
|
|
322
|
-
"""Auth and validation failures are final: retrying them only wastes time."""
|
|
323
|
-
attempts = {"n": 0}
|
|
324
|
-
|
|
325
|
-
def handler(request: httpx.Request) -> httpx.Response:
|
|
326
|
-
attempts["n"] += 1
|
|
327
|
-
return json_response({"error": {"code": "UNAUTHORIZED"}}, 401)
|
|
328
|
-
|
|
329
|
-
ark = Ark("token", client=client_for(handler))
|
|
330
|
-
with pytest.raises(ArkError):
|
|
331
|
-
ark.usage()
|
|
332
|
-
assert attempts["n"] == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|