nerdstack-ark 1.0.1__tar.gz → 1.0.3__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.3
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
@@ -130,6 +130,27 @@ session = ark.create_client_session(ttl_seconds=900)
130
130
  # Hand session.token to @nerdstackgrp/ark-client in the browser.
131
131
  ```
132
132
 
133
+ ## Ark Streams
134
+
135
+ Both synchronous and asynchronous clients expose the Ark Streams control
136
+ plane. Creating a video returns a TUS endpoint for the client that owns the
137
+ video bytes. A remote import is fetched directly by Ark's video provider.
138
+
139
+ ```python
140
+ creation = ark.streams.create("Product launch", size_bytes, app_id=app_id)
141
+ print(creation.stream.id, creation.upload.endpoint)
142
+
143
+ ark.streams.import_from_url("Remote video", source_url, app_id=app_id)
144
+ page = ark.streams.list(app_id=app_id, limit=50)
145
+ stream = ark.streams.get(creation.stream.id, app_id=app_id)
146
+ ark.streams.refresh_upload_url(stream.id, app_id=app_id)
147
+ ark.streams.delete(stream.id, app_id=app_id)
148
+ ```
149
+
150
+ The same methods on `AsyncArk.streams` are awaitable. Poll `get` while a video
151
+ is uploading or encoding; `hls_url` and `embed_url` are populated when its
152
+ status becomes `ready`.
153
+
133
154
  ## S3-compatible access
134
155
 
135
156
  ```python
@@ -87,6 +87,27 @@ session = ark.create_client_session(ttl_seconds=900)
87
87
  # Hand session.token to @nerdstackgrp/ark-client in the browser.
88
88
  ```
89
89
 
90
+ ## Ark Streams
91
+
92
+ Both synchronous and asynchronous clients expose the Ark Streams control
93
+ plane. Creating a video returns a TUS endpoint for the client that owns the
94
+ video bytes. A remote import is fetched directly by Ark's video provider.
95
+
96
+ ```python
97
+ creation = ark.streams.create("Product launch", size_bytes, app_id=app_id)
98
+ print(creation.stream.id, creation.upload.endpoint)
99
+
100
+ ark.streams.import_from_url("Remote video", source_url, app_id=app_id)
101
+ page = ark.streams.list(app_id=app_id, limit=50)
102
+ stream = ark.streams.get(creation.stream.id, app_id=app_id)
103
+ ark.streams.refresh_upload_url(stream.id, app_id=app_id)
104
+ ark.streams.delete(stream.id, app_id=app_id)
105
+ ```
106
+
107
+ The same methods on `AsyncArk.streams` are awaitable. Poll `get` while a video
108
+ is uploading or encoding; `hls_url` and `embed_url` are populated when its
109
+ status becomes `ready`.
110
+
90
111
  ## S3-compatible access
91
112
 
92
113
  ```python
@@ -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.3"
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"
@@ -5,11 +5,16 @@ from .errors import ArkError
5
5
  from .models import (
6
6
  ArkFile,
7
7
  ArkFolder,
8
+ ArkStream,
8
9
  ArkUsage,
9
10
  ClientSession,
10
11
  FilePage,
11
12
  ImageOptions,
12
13
  StorageUsage,
14
+ StreamCreation,
15
+ StreamPage,
16
+ StreamStatus,
17
+ StreamUploadTicket,
13
18
  )
14
19
  from .s3 import create_s3_client
15
20
  from .sync import Ark
@@ -19,13 +24,18 @@ __all__ = [
19
24
  "ArkError",
20
25
  "ArkFile",
21
26
  "ArkFolder",
27
+ "ArkStream",
22
28
  "ArkUsage",
23
29
  "AsyncArk",
24
30
  "ClientSession",
25
31
  "FilePage",
26
32
  "ImageOptions",
27
33
  "StorageUsage",
34
+ "StreamCreation",
35
+ "StreamPage",
36
+ "StreamStatus",
37
+ "StreamUploadTicket",
28
38
  "create_s3_client",
29
39
  ]
30
40
 
31
- __version__ = "1.0.1"
41
+ __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,
@@ -31,7 +27,18 @@ from ._shared import (
31
27
  validate_size,
32
28
  )
33
29
  from .errors import ArkError, error_from_response, invalid_argument, network_error, upload_error
34
- from .models import ArkFile, ArkFolder, ArkUsage, ClientSession, FilePage, ImageOptions
30
+ from .models import (
31
+ ArkFile,
32
+ ArkFolder,
33
+ ArkStream,
34
+ ArkUsage,
35
+ ClientSession,
36
+ FilePage,
37
+ ImageOptions,
38
+ StreamCreation,
39
+ StreamPage,
40
+ StreamUploadTicket,
41
+ )
35
42
 
36
43
  T = TypeVar("T")
37
44
  AsyncSource = str | os.PathLike[str] | BinaryIO | AsyncIterable[bytes]
@@ -60,6 +67,7 @@ class AsyncArk:
60
67
  self.folders = AsyncFolders(self)
61
68
  self.images = AsyncImages(self)
62
69
  self.imports = AsyncImports(self)
70
+ self.streams = AsyncStreams(self)
63
71
 
64
72
  async def __aenter__(self) -> AsyncArk:
65
73
  return self
@@ -105,28 +113,15 @@ class AsyncArk:
105
113
  *,
106
114
  json: Mapping[str, Any] | None = None,
107
115
  ) -> 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
116
+ try:
117
+ response = await self._client.request(
118
+ method,
119
+ self._url(path),
120
+ headers={"authorization": f"Bearer {self._token}"},
121
+ json=dict(json) if json is not None else None,
122
+ )
123
+ except httpx.HTTPError as error:
124
+ raise network_error(error) from error
130
125
  if response.is_error:
131
126
  raise error_from_response(response)
132
127
  if response.status_code == 204:
@@ -380,9 +375,10 @@ class AsyncFolders:
380
375
  "GET",
381
376
  f"/folders{query_string({'parentId': parent_id})}",
382
377
  )
378
+ raw_data = value.get("data")
383
379
  return tuple(
384
380
  ArkFolder.from_dict(item)
385
- for item in folders_payload(value)
381
+ for item in (raw_data if isinstance(raw_data, list) else [])
386
382
  if isinstance(item, Mapping)
387
383
  )
388
384
 
@@ -442,6 +438,94 @@ class AsyncImports:
442
438
  return bool(value.get("cancelled"))
443
439
 
444
440
 
441
+ class AsyncStreams:
442
+ """Asynchronous Ark Streams video control-plane APIs."""
443
+
444
+ def __init__(self, ark: AsyncArk) -> None:
445
+ self._ark = ark
446
+
447
+ async def create(
448
+ self,
449
+ title: str,
450
+ size_bytes: int,
451
+ *,
452
+ app_id: str | None = None,
453
+ collection_id: str | None = None,
454
+ ) -> StreamCreation:
455
+ payload: dict[str, Any] = {"title": title, "sizeBytes": size_bytes}
456
+ if app_id is not None:
457
+ payload["appId"] = app_id
458
+ if collection_id is not None:
459
+ payload["collectionId"] = collection_id
460
+ value = await self._ark._request("POST", "/streams", json=payload)
461
+ stream = value.get("stream")
462
+ upload = value.get("upload")
463
+ if not isinstance(stream, Mapping) or not isinstance(upload, Mapping):
464
+ raise ArkError("INTERNAL_ERROR", "Ark returned an invalid stream creation response")
465
+ return StreamCreation(
466
+ ArkStream.from_dict(stream),
467
+ StreamUploadTicket(str(upload["endpoint"])),
468
+ )
469
+
470
+ async def import_from_url(
471
+ self,
472
+ title: str,
473
+ url: str,
474
+ *,
475
+ app_id: str | None = None,
476
+ access_token: str | None = None,
477
+ size_bytes: int | None = None,
478
+ ) -> ArkStream:
479
+ payload: dict[str, Any] = {"title": title, "url": url}
480
+ if app_id is not None:
481
+ payload["appId"] = app_id
482
+ if access_token is not None:
483
+ payload["accessToken"] = access_token
484
+ if size_bytes is not None:
485
+ payload["sizeBytes"] = size_bytes
486
+ value = await self._ark._request("POST", "/streams/fetch", json=payload)
487
+ return ArkStream.from_dict(value)
488
+
489
+ async def list(
490
+ self,
491
+ *,
492
+ app_id: str | None = None,
493
+ limit: int | None = None,
494
+ cursor: str | None = None,
495
+ ) -> StreamPage:
496
+ suffix = query_string({"appId": app_id, "limit": limit, "cursor": cursor})
497
+ value = await self._ark._request("GET", f"/streams{suffix}")
498
+ raw_streams = value.get("streams")
499
+ streams = tuple(
500
+ ArkStream.from_dict(item)
501
+ for item in (raw_streams if isinstance(raw_streams, list) else [])
502
+ if isinstance(item, Mapping)
503
+ )
504
+ next_cursor = value.get("nextCursor")
505
+ return StreamPage(streams, next_cursor if isinstance(next_cursor, str) else None)
506
+
507
+ async def get(self, stream_id: str, *, app_id: str | None = None) -> ArkStream:
508
+ suffix = query_string({"appId": app_id})
509
+ value = await self._ark._request("GET", f"/streams/{segment(stream_id)}{suffix}")
510
+ return ArkStream.from_dict(value)
511
+
512
+ async def refresh_upload_url(
513
+ self,
514
+ stream_id: str,
515
+ *,
516
+ app_id: str | None = None,
517
+ ) -> StreamUploadTicket:
518
+ suffix = query_string({"appId": app_id})
519
+ value = await self._ark._request(
520
+ "POST", f"/streams/{segment(stream_id)}/upload-url{suffix}", json={}
521
+ )
522
+ return StreamUploadTicket(str(value["endpoint"]))
523
+
524
+ async def delete(self, stream_id: str, *, app_id: str | None = None) -> None:
525
+ suffix = query_string({"appId": app_id})
526
+ await self._ark._request("DELETE", f"/streams/{segment(stream_id)}{suffix}")
527
+
528
+
445
529
  def _as_async_iterable(source: AsyncSource) -> AsyncIterator[bytes] | None:
446
530
  method = getattr(source, "__aiter__", None)
447
531
  if method is None:
@@ -35,12 +35,23 @@ def error_from_response(response: httpx.Response) -> ArkError:
35
35
  body = response.json()
36
36
  except ValueError:
37
37
  body = {}
38
- envelope = body.get("error", {}) if isinstance(body, dict) else {}
39
- if not isinstance(envelope, dict):
40
- envelope = {}
38
+ raw_error = body.get("error") if isinstance(body, dict) else None
39
+ envelope = raw_error if isinstance(raw_error, dict) else {}
40
+ status_codes = {
41
+ 400: "INVALID_ARGUMENT",
42
+ 401: "UNAUTHORIZED",
43
+ 402: "QUOTA_EXCEEDED",
44
+ 403: "INSUFFICIENT_SCOPE",
45
+ 404: "NOT_FOUND",
46
+ 429: "RATE_LIMITED",
47
+ }
41
48
  return ArkError(
42
- str(envelope.get("code") or "INTERNAL_ERROR"),
43
- str(envelope.get("message") or f"Request failed with status {response.status_code}"),
49
+ str(envelope.get("code") or status_codes.get(response.status_code, "INTERNAL_ERROR")),
50
+ str(
51
+ envelope.get("message")
52
+ or (raw_error if isinstance(raw_error, str) else None)
53
+ or f"Request failed with status {response.status_code}"
54
+ ),
44
55
  status=response.status_code,
45
56
  request_id=_optional_string(envelope.get("requestId")),
46
57
  details=envelope.get("details") if isinstance(envelope.get("details"), dict) else None,
@@ -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
 
@@ -86,6 +96,61 @@ class FilePage:
86
96
  next_cursor: str | None
87
97
 
88
98
 
99
+ StreamStatus = Literal["created", "uploading", "processing", "ready", "failed"]
100
+
101
+
102
+ @dataclass(frozen=True, slots=True)
103
+ class ArkStream:
104
+ """A video managed by Ark Streams. Playback URLs are None until it is ready."""
105
+
106
+ id: str
107
+ title: str
108
+ status: StreamStatus | str
109
+ encode_progress: int
110
+ duration_seconds: int
111
+ width: int
112
+ height: int
113
+ size: int
114
+ thumbnail_url: str | None
115
+ hls_url: str | None
116
+ embed_url: str | None
117
+ created_at: str
118
+
119
+ @classmethod
120
+ def from_dict(cls, value: Mapping[str, Any]) -> ArkStream:
121
+ return cls(
122
+ id=str(value["id"]),
123
+ title=str(value["title"]),
124
+ status=str(value.get("status") or "created"),
125
+ encode_progress=int(value.get("encodeProgress") or 0),
126
+ duration_seconds=int(value.get("durationSeconds") or 0),
127
+ width=int(value.get("width") or 0),
128
+ height=int(value.get("height") or 0),
129
+ size=int(value.get("size") or 0),
130
+ thumbnail_url=_optional_string(value.get("thumbnailUrl")),
131
+ hls_url=_optional_string(value.get("hlsUrl")),
132
+ embed_url=_optional_string(value.get("embedUrl")),
133
+ created_at=str(value.get("createdAt") or ""),
134
+ )
135
+
136
+
137
+ @dataclass(frozen=True, slots=True)
138
+ class StreamUploadTicket:
139
+ endpoint: str
140
+
141
+
142
+ @dataclass(frozen=True, slots=True)
143
+ class StreamCreation:
144
+ stream: ArkStream
145
+ upload: StreamUploadTicket
146
+
147
+
148
+ @dataclass(frozen=True, slots=True)
149
+ class StreamPage:
150
+ streams: tuple[ArkStream, ...]
151
+ next_cursor: str | None
152
+
153
+
89
154
  @dataclass(frozen=True, slots=True)
90
155
  class ClientSession:
91
156
  token: str
@@ -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,
@@ -32,7 +27,18 @@ from ._shared import (
32
27
  upload_payload,
33
28
  )
34
29
  from .errors import ArkError, error_from_response, network_error, upload_error
35
- from .models import ArkFile, ArkFolder, ArkUsage, ClientSession, FilePage, ImageOptions
30
+ from .models import (
31
+ ArkFile,
32
+ ArkFolder,
33
+ ArkStream,
34
+ ArkUsage,
35
+ ClientSession,
36
+ FilePage,
37
+ ImageOptions,
38
+ StreamCreation,
39
+ StreamPage,
40
+ StreamUploadTicket,
41
+ )
36
42
 
37
43
  T = TypeVar("T")
38
44
 
@@ -60,6 +66,7 @@ class Ark:
60
66
  self.folders = Folders(self)
61
67
  self.images = Images(self)
62
68
  self.imports = Imports(self)
69
+ self.streams = Streams(self)
63
70
 
64
71
  def __enter__(self) -> Ark:
65
72
  return self
@@ -105,32 +112,15 @@ class Ark:
105
112
  *,
106
113
  json: Mapping[str, Any] | None = None,
107
114
  ) -> 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
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
+ raise network_error(error) from error
134
124
  if response.is_error:
135
125
  raise error_from_response(response)
136
126
  if response.status_code == 204:
@@ -344,9 +334,10 @@ class Folders:
344
334
 
345
335
  def list(self, *, parent_id: str | None = None) -> tuple[ArkFolder, ...]:
346
336
  value = self._ark._request("GET", f"/folders{query_string({'parentId': parent_id})}")
337
+ raw_data = value.get("data")
347
338
  return tuple(
348
339
  ArkFolder.from_dict(item)
349
- for item in folders_payload(value)
340
+ for item in (raw_data if isinstance(raw_data, list) else [])
350
341
  if isinstance(item, Mapping)
351
342
  )
352
343
 
@@ -400,3 +391,91 @@ class Imports:
400
391
  def cancel(self, import_id: str) -> bool:
401
392
  value = self._ark._request("POST", f"/imports/{segment(import_id)}/cancel", json={})
402
393
  return bool(value.get("cancelled"))
394
+
395
+
396
+ class Streams:
397
+ """Ark Streams video creation, import, playback metadata, and lifecycle APIs."""
398
+
399
+ def __init__(self, ark: Ark) -> None:
400
+ self._ark = ark
401
+
402
+ def create(
403
+ self,
404
+ title: str,
405
+ size_bytes: int,
406
+ *,
407
+ app_id: str | None = None,
408
+ collection_id: str | None = None,
409
+ ) -> StreamCreation:
410
+ payload: dict[str, Any] = {"title": title, "sizeBytes": size_bytes}
411
+ if app_id is not None:
412
+ payload["appId"] = app_id
413
+ if collection_id is not None:
414
+ payload["collectionId"] = collection_id
415
+ value = self._ark._request("POST", "/streams", json=payload)
416
+ stream = value.get("stream")
417
+ upload = value.get("upload")
418
+ if not isinstance(stream, Mapping) or not isinstance(upload, Mapping):
419
+ raise ArkError("INTERNAL_ERROR", "Ark returned an invalid stream creation response")
420
+ return StreamCreation(
421
+ ArkStream.from_dict(stream),
422
+ StreamUploadTicket(str(upload["endpoint"])),
423
+ )
424
+
425
+ def import_from_url(
426
+ self,
427
+ title: str,
428
+ url: str,
429
+ *,
430
+ app_id: str | None = None,
431
+ access_token: str | None = None,
432
+ size_bytes: int | None = None,
433
+ ) -> ArkStream:
434
+ payload: dict[str, Any] = {"title": title, "url": url}
435
+ if app_id is not None:
436
+ payload["appId"] = app_id
437
+ if access_token is not None:
438
+ payload["accessToken"] = access_token
439
+ if size_bytes is not None:
440
+ payload["sizeBytes"] = size_bytes
441
+ return ArkStream.from_dict(self._ark._request("POST", "/streams/fetch", json=payload))
442
+
443
+ def list(
444
+ self,
445
+ *,
446
+ app_id: str | None = None,
447
+ limit: int | None = None,
448
+ cursor: str | None = None,
449
+ ) -> StreamPage:
450
+ suffix = query_string({"appId": app_id, "limit": limit, "cursor": cursor})
451
+ value = self._ark._request("GET", f"/streams{suffix}")
452
+ raw_streams = value.get("streams")
453
+ streams = tuple(
454
+ ArkStream.from_dict(item)
455
+ for item in (raw_streams if isinstance(raw_streams, list) else [])
456
+ if isinstance(item, Mapping)
457
+ )
458
+ next_cursor = value.get("nextCursor")
459
+ return StreamPage(streams, next_cursor if isinstance(next_cursor, str) else None)
460
+
461
+ def get(self, stream_id: str, *, app_id: str | None = None) -> ArkStream:
462
+ suffix = query_string({"appId": app_id})
463
+ return ArkStream.from_dict(
464
+ self._ark._request("GET", f"/streams/{segment(stream_id)}{suffix}")
465
+ )
466
+
467
+ def refresh_upload_url(
468
+ self,
469
+ stream_id: str,
470
+ *,
471
+ app_id: str | None = None,
472
+ ) -> StreamUploadTicket:
473
+ suffix = query_string({"appId": app_id})
474
+ value = self._ark._request(
475
+ "POST", f"/streams/{segment(stream_id)}/upload-url{suffix}", json={}
476
+ )
477
+ return StreamUploadTicket(str(value["endpoint"]))
478
+
479
+ def delete(self, stream_id: str, *, app_id: str | None = None) -> None:
480
+ suffix = query_string({"appId": app_id})
481
+ self._ark._request("DELETE", f"/streams/{segment(stream_id)}{suffix}")
@@ -8,6 +8,7 @@ from typing import Any
8
8
  import httpx
9
9
  import pytest
10
10
  from conftest import file_response, json_response
11
+ from test_sync import STREAM_RESPONSE
11
12
 
12
13
  from ark_py import ArkError, AsyncArk
13
14
 
@@ -42,6 +43,35 @@ async def test_async_resources_and_default_url() -> None:
42
43
  await client.aclose()
43
44
 
44
45
 
46
+ @pytest.mark.asyncio
47
+ async def test_async_streams_control_plane() -> None:
48
+ async def handler(request: httpx.Request) -> httpx.Response:
49
+ if request.url.path.endswith("/streams") and request.method == "POST":
50
+ return json_response(
51
+ {"stream": STREAM_RESPONSE, "upload": {"endpoint": "/streams/stream-1/upload"}},
52
+ 201,
53
+ )
54
+ if request.url.path.endswith("/streams/fetch"):
55
+ return json_response(STREAM_RESPONSE, 202)
56
+ if request.url.path.endswith("/upload-url"):
57
+ return json_response({"endpoint": "/streams/stream-1/upload"})
58
+ if request.method == "DELETE":
59
+ return httpx.Response(204)
60
+ if request.url.path.endswith("/stream-1"):
61
+ return json_response(STREAM_RESPONSE)
62
+ return json_response({"streams": [STREAM_RESPONSE], "nextCursor": None})
63
+
64
+ client = async_client_for(handler)
65
+ streams = AsyncArk("token", base_url="https://ark.test", client=client).streams
66
+ assert (await streams.create("Launch", 42)).stream.id == "stream-1"
67
+ assert (await streams.import_from_url("Remote", "https://video.test/a.mp4")).size == 42
68
+ assert len((await streams.list(app_id="app-1")).streams) == 1
69
+ assert (await streams.get("stream-1")).status == "ready"
70
+ assert (await streams.refresh_upload_url("stream-1")).endpoint.endswith("/upload")
71
+ assert await streams.delete("stream-1") is None
72
+ await client.aclose()
73
+
74
+
45
75
  @pytest.mark.asyncio
46
76
  async def test_async_iterable_multipart_upload_is_bounded() -> None:
47
77
  data = bytes(range(10))
@@ -13,6 +13,21 @@ from conftest import file_response, json_response
13
13
 
14
14
  from ark_py import Ark, ArkError, ImageOptions
15
15
 
16
+ STREAM_RESPONSE = {
17
+ "id": "stream-1",
18
+ "title": "Launch",
19
+ "status": "ready",
20
+ "encodeProgress": 100,
21
+ "durationSeconds": 12,
22
+ "width": 1920,
23
+ "height": 1080,
24
+ "size": 42,
25
+ "thumbnailUrl": "https://cdn.test/thumb.jpg",
26
+ "hlsUrl": "https://cdn.test/playlist.m3u8",
27
+ "embedUrl": "https://player.test/embed",
28
+ "createdAt": "2026-09-03T00:00:00.000Z",
29
+ }
30
+
16
31
 
17
32
  def client_for(handler: Any) -> httpx.Client:
18
33
  return httpx.Client(transport=httpx.MockTransport(handler))
@@ -87,6 +102,38 @@ def test_resources_models_images_and_errors() -> None:
87
102
  client.close()
88
103
 
89
104
 
105
+ def test_streams_control_plane() -> None:
106
+ requests: list[tuple[str, str]] = []
107
+
108
+ def handler(request: httpx.Request) -> httpx.Response:
109
+ requests.append((request.method, str(request.url)))
110
+ if request.url.path.endswith("/streams") and request.method == "POST":
111
+ return json_response(
112
+ {"stream": STREAM_RESPONSE, "upload": {"endpoint": "/streams/stream-1/upload"}},
113
+ 201,
114
+ )
115
+ if request.url.path.endswith("/streams/fetch"):
116
+ return json_response(STREAM_RESPONSE, 202)
117
+ if request.url.path.endswith("/upload-url"):
118
+ return json_response({"endpoint": "/streams/stream-1/upload?appId=app-1"})
119
+ if request.method == "DELETE":
120
+ return httpx.Response(204)
121
+ if request.url.path.endswith("/stream-1"):
122
+ return json_response(STREAM_RESPONSE)
123
+ return json_response({"streams": [STREAM_RESPONSE], "nextCursor": "next"})
124
+
125
+ client = client_for(handler)
126
+ streams = Ark("token", base_url="https://ark.test", client=client).streams
127
+ assert streams.create("Launch", 42, app_id="app-1").upload.endpoint.endswith("/upload")
128
+ assert streams.import_from_url("Remote", "https://video.test/a.mp4").id == "stream-1"
129
+ assert streams.list(app_id="app-1", limit=10).next_cursor == "next"
130
+ assert streams.get("stream-1", app_id="app-1").hls_url is not None
131
+ assert streams.refresh_upload_url("stream-1", app_id="app-1").endpoint.endswith("appId=app-1")
132
+ assert streams.delete("stream-1", app_id="app-1") is None
133
+ assert requests[2] == ("GET", "https://ark.test/api/v2/streams?appId=app-1&limit=10")
134
+ client.close()
135
+
136
+
90
137
  def test_single_path_upload_streams_and_completes(tmp_path: Path) -> None:
91
138
  data = b"streamed from disk"
92
139
  path = tmp_path / "photo.jpg"
@@ -237,96 +284,3 @@ def test_non_seekable_stream_requires_size_and_filename() -> None:
237
284
  with pytest.raises(ArkError, match="filename is required"):
238
285
  ark.files.upload(NonSeekable(b"data"), size=4)
239
286
  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