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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: nerdstack-ark
3
- Version: 1.0.1
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nerdstack-ark"
7
- version = "1.0.1"
7
+ version = "1.0.2"
8
8
  description = "Official Python SDK for Ark storage, with sync, async, and S3-compatible access."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -28,4 +28,4 @@ __all__ = [
28
28
  "create_s3_client",
29
29
  ]
30
30
 
31
- __version__ = "1.0.1"
31
+ __version__ = "1.0.0"
@@ -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
- # 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
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 folders_payload(value)
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
- # 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
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 folders_payload(value)
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