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.
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.3}/PKG-INFO +22 -1
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.3}/README.md +21 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.3}/pyproject.toml +1 -1
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.3}/src/ark_py/__init__.py +11 -1
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.3}/src/ark_py/_shared.py +0 -39
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.3}/src/ark_py/async_client.py +112 -28
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.3}/src/ark_py/errors.py +16 -5
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.3}/src/ark_py/models.py +65 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.3}/src/ark_py/sync.py +112 -33
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.3}/tests/test_async.py +30 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.3}/tests/test_sync.py +47 -93
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.3}/.gitignore +0 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.3}/LICENSE +0 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.3}/examples/django/apps.py +0 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.3}/examples/django/views.py +0 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.3}/examples/fastapi/app.py +0 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.3}/examples/flask/app.py +0 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.3}/src/ark_py/py.typed +0 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.3}/src/ark_py/s3.py +0 -0
- {nerdstack_ark-1.0.1 → nerdstack_ark-1.0.3}/tests/conftest.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.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
|
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
39
|
-
if
|
|
40
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|