nerdstack-ark 1.0.0__tar.gz → 1.0.1__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.0 → nerdstack_ark-1.0.1}/PKG-INFO +1 -1
- {nerdstack_ark-1.0.0 → nerdstack_ark-1.0.1}/pyproject.toml +1 -1
- {nerdstack_ark-1.0.0 → nerdstack_ark-1.0.1}/src/ark_py/__init__.py +1 -1
- {nerdstack_ark-1.0.0 → nerdstack_ark-1.0.1}/src/ark_py/_shared.py +39 -0
- {nerdstack_ark-1.0.0 → nerdstack_ark-1.0.1}/src/ark_py/async_client.py +27 -11
- {nerdstack_ark-1.0.0 → nerdstack_ark-1.0.1}/src/ark_py/sync.py +32 -11
- {nerdstack_ark-1.0.0 → nerdstack_ark-1.0.1}/tests/test_sync.py +93 -0
- {nerdstack_ark-1.0.0 → nerdstack_ark-1.0.1}/.gitignore +0 -0
- {nerdstack_ark-1.0.0 → nerdstack_ark-1.0.1}/LICENSE +0 -0
- {nerdstack_ark-1.0.0 → nerdstack_ark-1.0.1}/README.md +0 -0
- {nerdstack_ark-1.0.0 → nerdstack_ark-1.0.1}/examples/django/apps.py +0 -0
- {nerdstack_ark-1.0.0 → nerdstack_ark-1.0.1}/examples/django/views.py +0 -0
- {nerdstack_ark-1.0.0 → nerdstack_ark-1.0.1}/examples/fastapi/app.py +0 -0
- {nerdstack_ark-1.0.0 → nerdstack_ark-1.0.1}/examples/flask/app.py +0 -0
- {nerdstack_ark-1.0.0 → nerdstack_ark-1.0.1}/src/ark_py/errors.py +0 -0
- {nerdstack_ark-1.0.0 → nerdstack_ark-1.0.1}/src/ark_py/models.py +0 -0
- {nerdstack_ark-1.0.0 → nerdstack_ark-1.0.1}/src/ark_py/py.typed +0 -0
- {nerdstack_ark-1.0.0 → nerdstack_ark-1.0.1}/src/ark_py/s3.py +0 -0
- {nerdstack_ark-1.0.0 → nerdstack_ark-1.0.1}/tests/conftest.py +0 -0
- {nerdstack_ark-1.0.0 → nerdstack_ark-1.0.1}/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.1
|
|
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,3 +180,42 @@ 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,7 +15,11 @@ from ._shared import (
|
|
|
15
15
|
DEFAULT_BASE_URL,
|
|
16
16
|
DEFAULT_CONTENT_TYPE,
|
|
17
17
|
UploadSource,
|
|
18
|
+
MAX_REQUEST_ATTEMPTS,
|
|
18
19
|
api_url,
|
|
20
|
+
folders_payload,
|
|
21
|
+
is_retryable_status,
|
|
22
|
+
retry_delay_seconds,
|
|
19
23
|
image_url,
|
|
20
24
|
parse_client_session,
|
|
21
25
|
query_string,
|
|
@@ -101,15 +105,28 @@ class AsyncArk:
|
|
|
101
105
|
*,
|
|
102
106
|
json: Mapping[str, Any] | None = None,
|
|
103
107
|
) -> dict[str, Any]:
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
108
|
+
# See the sync client: retried with backoff so a transient failure on a
|
|
109
|
+
# large upload does not surface as an error the caller must retry.
|
|
110
|
+
response = None
|
|
111
|
+
for attempt in range(1, MAX_REQUEST_ATTEMPTS + 1):
|
|
112
|
+
try:
|
|
113
|
+
response = await self._client.request(
|
|
114
|
+
method,
|
|
115
|
+
self._url(path),
|
|
116
|
+
headers={"authorization": f"Bearer {self._token}"},
|
|
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
|
|
113
130
|
if response.is_error:
|
|
114
131
|
raise error_from_response(response)
|
|
115
132
|
if response.status_code == 204:
|
|
@@ -363,10 +380,9 @@ class AsyncFolders:
|
|
|
363
380
|
"GET",
|
|
364
381
|
f"/folders{query_string({'parentId': parent_id})}",
|
|
365
382
|
)
|
|
366
|
-
raw_data = value.get("data")
|
|
367
383
|
return tuple(
|
|
368
384
|
ArkFolder.from_dict(item)
|
|
369
|
-
for item in (
|
|
385
|
+
for item in folders_payload(value)
|
|
370
386
|
if isinstance(item, Mapping)
|
|
371
387
|
)
|
|
372
388
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import builtins
|
|
4
|
+
import time
|
|
4
5
|
from collections.abc import Mapping
|
|
5
6
|
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
|
|
6
7
|
from contextlib import suppress
|
|
@@ -14,7 +15,11 @@ from ._shared import (
|
|
|
14
15
|
DEFAULT_BASE_URL,
|
|
15
16
|
UploadSource,
|
|
16
17
|
api_url,
|
|
18
|
+
MAX_REQUEST_ATTEMPTS,
|
|
17
19
|
ensure_stream_complete,
|
|
20
|
+
folders_payload,
|
|
21
|
+
is_retryable_status,
|
|
22
|
+
retry_delay_seconds,
|
|
18
23
|
image_url,
|
|
19
24
|
iter_exact,
|
|
20
25
|
iter_file_range,
|
|
@@ -100,15 +105,32 @@ class Ark:
|
|
|
100
105
|
*,
|
|
101
106
|
json: Mapping[str, Any] | None = None,
|
|
102
107
|
) -> dict[str, Any]:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
108
|
+
# Retried with backoff, matching the TypeScript client. A transient
|
|
109
|
+
# network failure on a multi-megabyte upload otherwise surfaced as a
|
|
110
|
+
# bare NETWORK_ERROR that succeeded on the caller's own retry -- work
|
|
111
|
+
# every caller had to reimplement.
|
|
112
|
+
response = None
|
|
113
|
+
last_error: Exception | None = None
|
|
114
|
+
for attempt in range(1, MAX_REQUEST_ATTEMPTS + 1):
|
|
115
|
+
try:
|
|
116
|
+
response = self._client.request(
|
|
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
|
|
112
134
|
if response.is_error:
|
|
113
135
|
raise error_from_response(response)
|
|
114
136
|
if response.status_code == 204:
|
|
@@ -322,10 +344,9 @@ class Folders:
|
|
|
322
344
|
|
|
323
345
|
def list(self, *, parent_id: str | None = None) -> tuple[ArkFolder, ...]:
|
|
324
346
|
value = self._ark._request("GET", f"/folders{query_string({'parentId': parent_id})}")
|
|
325
|
-
raw_data = value.get("data")
|
|
326
347
|
return tuple(
|
|
327
348
|
ArkFolder.from_dict(item)
|
|
328
|
-
for item in (
|
|
349
|
+
for item in folders_payload(value)
|
|
329
350
|
if isinstance(item, Mapping)
|
|
330
351
|
)
|
|
331
352
|
|
|
@@ -237,3 +237,96 @@ 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
|
|
File without changes
|