nerdstack-ark 1.0.3__tar.gz → 1.0.5__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.5/CHANGELOG.md +34 -0
- {nerdstack_ark-1.0.3 → nerdstack_ark-1.0.5}/PKG-INFO +20 -7
- {nerdstack_ark-1.0.3 → nerdstack_ark-1.0.5}/README.md +19 -6
- {nerdstack_ark-1.0.3 → nerdstack_ark-1.0.5}/pyproject.toml +1 -1
- {nerdstack_ark-1.0.3 → nerdstack_ark-1.0.5}/src/ark_py/__init__.py +10 -1
- {nerdstack_ark-1.0.3 → nerdstack_ark-1.0.5}/src/ark_py/_shared.py +73 -1
- {nerdstack_ark-1.0.3 → nerdstack_ark-1.0.5}/src/ark_py/async_client.py +141 -7
- {nerdstack_ark-1.0.3 → nerdstack_ark-1.0.5}/src/ark_py/errors.py +17 -0
- {nerdstack_ark-1.0.3 → nerdstack_ark-1.0.5}/src/ark_py/models.py +9 -1
- {nerdstack_ark-1.0.3 → nerdstack_ark-1.0.5}/src/ark_py/sync.py +150 -7
- {nerdstack_ark-1.0.3 → nerdstack_ark-1.0.5}/tests/test_async.py +65 -0
- nerdstack_ark-1.0.5/tests/test_stream_upload.py +168 -0
- {nerdstack_ark-1.0.3 → nerdstack_ark-1.0.5}/tests/test_sync.py +89 -1
- {nerdstack_ark-1.0.3 → nerdstack_ark-1.0.5}/.gitignore +0 -0
- {nerdstack_ark-1.0.3 → nerdstack_ark-1.0.5}/LICENSE +0 -0
- {nerdstack_ark-1.0.3 → nerdstack_ark-1.0.5}/examples/django/apps.py +0 -0
- {nerdstack_ark-1.0.3 → nerdstack_ark-1.0.5}/examples/django/views.py +0 -0
- {nerdstack_ark-1.0.3 → nerdstack_ark-1.0.5}/examples/fastapi/app.py +0 -0
- {nerdstack_ark-1.0.3 → nerdstack_ark-1.0.5}/examples/flask/app.py +0 -0
- {nerdstack_ark-1.0.3 → nerdstack_ark-1.0.5}/src/ark_py/py.typed +0 -0
- {nerdstack_ark-1.0.3 → nerdstack_ark-1.0.5}/src/ark_py/s3.py +0 -0
- {nerdstack_ark-1.0.3 → nerdstack_ark-1.0.5}/tests/conftest.py +0 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.5
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- `folders.list()` and `AsyncFolders.list()` returned an empty tuple against
|
|
8
|
+
every real workspace. `GET /api/v2/folders` returns the array under
|
|
9
|
+
`folders`, but the SDK read `data` — the key `/api/v2/files` uses. The
|
|
10
|
+
missing key was silently coerced to an empty list, so callers that resolve a
|
|
11
|
+
folder by name saw "no such folder", called `create()`, and got back
|
|
12
|
+
"A folder with this name already exists here". Nesting was unusable for the
|
|
13
|
+
same reason, since resolving `a/b/c` lists children at each level.
|
|
14
|
+
|
|
15
|
+
Both envelopes are now accepted, so an older deployment keeps working.
|
|
16
|
+
|
|
17
|
+
- A folder-list body the SDK cannot parse now raises `ArkError` with code
|
|
18
|
+
`INVALID_RESPONSE` instead of being reported as an empty list. The silent
|
|
19
|
+
empty result is what made the bug above expensive to diagnose: `list()` and
|
|
20
|
+
`create()` reported opposite things and neither raised.
|
|
21
|
+
|
|
22
|
+
- `ArkError` now exposes `message`. It was documented and passed to
|
|
23
|
+
`Exception.__init__`, but never stored, so `error.message` raised
|
|
24
|
+
`AttributeError` inside callers' own error handlers. `str(error)` was the
|
|
25
|
+
only thing that worked.
|
|
26
|
+
|
|
27
|
+
- `ark_py.__version__` read `1.0.0` while the package was on 1.0.4. It is now
|
|
28
|
+
read from the installed distribution metadata, so it cannot drift again.
|
|
29
|
+
|
|
30
|
+
### Internal
|
|
31
|
+
|
|
32
|
+
- Folder parsing moved to `_shared.parse_folder_list`. The identical code was
|
|
33
|
+
duplicated in the sync and async clients, which is why one bug needed fixing
|
|
34
|
+
in two places, and why the async client had no folder test at all.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: nerdstack-ark
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.5
|
|
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
|
|
@@ -132,9 +132,21 @@ session = ark.create_client_session(ttl_seconds=900)
|
|
|
132
132
|
|
|
133
133
|
## Ark Streams
|
|
134
134
|
|
|
135
|
-
Both synchronous and asynchronous clients expose
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
Both synchronous and asynchronous clients expose Ark Streams. `upload` is the
|
|
136
|
+
one call most applications need: it creates the video and sends the bytes,
|
|
137
|
+
resuming from the server's acknowledged offset if the connection drops.
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
stream = ark.streams.upload(
|
|
141
|
+
"./launch.mp4",
|
|
142
|
+
title="Product launch",
|
|
143
|
+
app_id=app_id,
|
|
144
|
+
on_progress=lambda sent, total: print(f"{sent}/{total}"),
|
|
145
|
+
)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Use `create` directly when the bytes belong to someone else -- a browser, or a
|
|
149
|
+
worker -- and you only need the upload ticket:
|
|
138
150
|
|
|
139
151
|
```python
|
|
140
152
|
creation = ark.streams.create("Product launch", size_bytes, app_id=app_id)
|
|
@@ -147,9 +159,10 @@ ark.streams.refresh_upload_url(stream.id, app_id=app_id)
|
|
|
147
159
|
ark.streams.delete(stream.id, app_id=app_id)
|
|
148
160
|
```
|
|
149
161
|
|
|
150
|
-
The same methods on `AsyncArk.streams` are awaitable.
|
|
151
|
-
|
|
152
|
-
|
|
162
|
+
The same methods on `AsyncArk.streams` are awaitable. Encoding continues after
|
|
163
|
+
an upload returns, so poll `get` until `status` is `ready`; `hls_url`,
|
|
164
|
+
`thumbnail_url` and `embed_url` are `None` until then. `embed_url` is an
|
|
165
|
+
Ark-hosted player page that can go straight into an iframe.
|
|
153
166
|
|
|
154
167
|
## S3-compatible access
|
|
155
168
|
|
|
@@ -89,9 +89,21 @@ session = ark.create_client_session(ttl_seconds=900)
|
|
|
89
89
|
|
|
90
90
|
## Ark Streams
|
|
91
91
|
|
|
92
|
-
Both synchronous and asynchronous clients expose
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
Both synchronous and asynchronous clients expose Ark Streams. `upload` is the
|
|
93
|
+
one call most applications need: it creates the video and sends the bytes,
|
|
94
|
+
resuming from the server's acknowledged offset if the connection drops.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
stream = ark.streams.upload(
|
|
98
|
+
"./launch.mp4",
|
|
99
|
+
title="Product launch",
|
|
100
|
+
app_id=app_id,
|
|
101
|
+
on_progress=lambda sent, total: print(f"{sent}/{total}"),
|
|
102
|
+
)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Use `create` directly when the bytes belong to someone else -- a browser, or a
|
|
106
|
+
worker -- and you only need the upload ticket:
|
|
95
107
|
|
|
96
108
|
```python
|
|
97
109
|
creation = ark.streams.create("Product launch", size_bytes, app_id=app_id)
|
|
@@ -104,9 +116,10 @@ ark.streams.refresh_upload_url(stream.id, app_id=app_id)
|
|
|
104
116
|
ark.streams.delete(stream.id, app_id=app_id)
|
|
105
117
|
```
|
|
106
118
|
|
|
107
|
-
The same methods on `AsyncArk.streams` are awaitable.
|
|
108
|
-
|
|
109
|
-
|
|
119
|
+
The same methods on `AsyncArk.streams` are awaitable. Encoding continues after
|
|
120
|
+
an upload returns, so poll `get` until `status` is `ready`; `hls_url`,
|
|
121
|
+
`thumbnail_url` and `embed_url` are `None` until then. `embed_url` is an
|
|
122
|
+
Ark-hosted player page that can go straight into an iframe.
|
|
110
123
|
|
|
111
124
|
## S3-compatible access
|
|
112
125
|
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
"""Official Python SDK for Ark storage."""
|
|
2
2
|
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _version
|
|
5
|
+
|
|
3
6
|
from .async_client import AsyncArk
|
|
4
7
|
from .errors import ArkError
|
|
5
8
|
from .models import (
|
|
@@ -38,4 +41,10 @@ __all__ = [
|
|
|
38
41
|
"create_s3_client",
|
|
39
42
|
]
|
|
40
43
|
|
|
41
|
-
|
|
44
|
+
# Read from the installed distribution rather than hard-coded. This said
|
|
45
|
+
# "1.0.0" while pyproject.toml said 1.0.4, so anyone reporting a bug with
|
|
46
|
+
# ark_py.__version__ quoted a version that had not shipped in months.
|
|
47
|
+
try: # pragma: no cover - depends on install state, not on logic
|
|
48
|
+
__version__ = _version("nerdstack-ark")
|
|
49
|
+
except PackageNotFoundError: # running from a source tree, not installed
|
|
50
|
+
__version__ = "0.0.0.dev0"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import base64
|
|
3
4
|
import mimetypes
|
|
4
5
|
import os
|
|
5
6
|
from collections.abc import Iterable, Iterator, Mapping
|
|
@@ -8,7 +9,7 @@ from pathlib import Path
|
|
|
8
9
|
from typing import Any, BinaryIO
|
|
9
10
|
from urllib.parse import quote, urlencode
|
|
10
11
|
|
|
11
|
-
from .errors import invalid_argument
|
|
12
|
+
from .errors import invalid_argument, invalid_response
|
|
12
13
|
from .models import ClientSession, ImageOptions
|
|
13
14
|
|
|
14
15
|
DEFAULT_BASE_URL = "https://ark.nerdstackgrp.com"
|
|
@@ -41,6 +42,34 @@ def image_url(base_url: str, version: str, asset_id: str, options: ImageOptions)
|
|
|
41
42
|
return api_url(base_url, version, f"/assets/{segment(asset_id)}/image{suffix}")
|
|
42
43
|
|
|
43
44
|
|
|
45
|
+
def parse_folder_list(value: Mapping[str, Any]) -> list[Mapping[str, Any]]:
|
|
46
|
+
"""Pull the folder array out of a GET /folders body.
|
|
47
|
+
|
|
48
|
+
The two list endpoints do not share an envelope: /files returns
|
|
49
|
+
``{"data": [...], "nextCursor": ...}`` while /folders returns
|
|
50
|
+
``{"folders": [...], "pagination": {...}}``. The SDK read ``data`` for
|
|
51
|
+
both, so folders.list() returned nothing against every real workspace --
|
|
52
|
+
silently, because a missing key was coerced to an empty list.
|
|
53
|
+
|
|
54
|
+
Both keys are accepted so an older deployment keeps working, and anything
|
|
55
|
+
else raises rather than reporting an empty folder list that the caller
|
|
56
|
+
cannot distinguish from a real one.
|
|
57
|
+
|
|
58
|
+
Lives here rather than in each client because the identical parsing was
|
|
59
|
+
duplicated in sync.py and async_client.py -- which is why one bug needed
|
|
60
|
+
fixing in two places.
|
|
61
|
+
"""
|
|
62
|
+
for key in ("folders", "data"):
|
|
63
|
+
raw = value.get(key)
|
|
64
|
+
if isinstance(raw, list):
|
|
65
|
+
return [item for item in raw if isinstance(item, Mapping)]
|
|
66
|
+
|
|
67
|
+
raise invalid_response(
|
|
68
|
+
"The folder list response was not in a recognised format. "
|
|
69
|
+
f"Expected a 'folders' array, got keys: {sorted(map(str, value.keys())) or 'none'}."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
44
73
|
def parse_client_session(value: Mapping[str, Any]) -> ClientSession:
|
|
45
74
|
raw_scopes = value.get("scopes")
|
|
46
75
|
scopes = tuple(str(scope) for scope in raw_scopes) if isinstance(raw_scopes, list) else ()
|
|
@@ -180,3 +209,46 @@ def sorted_parts(parts: Iterable[dict[str, object]]) -> list[dict[str, object]]:
|
|
|
180
209
|
return value
|
|
181
210
|
|
|
182
211
|
return sorted(parts, key=part_number)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# --- Ark Streams resumable upload (TUS) -------------------------------------
|
|
215
|
+
#
|
|
216
|
+
# Video goes to the encoding network over TUS rather than through the presigned
|
|
217
|
+
# path files use: an encode is long enough that a dropped connection is normal
|
|
218
|
+
# rather than exceptional, so the protocol has to be able to say "you already
|
|
219
|
+
# have the first N bytes, continue from there".
|
|
220
|
+
|
|
221
|
+
#: TUS sends metadata as base64, so a filename with non-ASCII characters
|
|
222
|
+
#: survives the header intact.
|
|
223
|
+
def tus_metadata(value: str) -> str:
|
|
224
|
+
return base64.b64encode(value.encode("utf-8")).decode("ascii")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
#: Default slice sent per PATCH. Large enough that a long video is not thousands
|
|
228
|
+
#: of round trips, small enough that a failure re-sends little.
|
|
229
|
+
DEFAULT_VIDEO_CHUNK_SIZE = 64 * 1024 * 1024
|
|
230
|
+
|
|
231
|
+
#: Consecutive failures tolerated per chunk before giving up. Each retry first
|
|
232
|
+
#: asks the server what it actually holds, so a retry never duplicates bytes.
|
|
233
|
+
MAX_VIDEO_CHUNK_RETRIES = 2
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def validate_chunk_size(chunk_size: int) -> None:
|
|
237
|
+
if isinstance(chunk_size, bool) or not isinstance(chunk_size, int) or chunk_size <= 0:
|
|
238
|
+
raise invalid_argument("chunk_size must be a positive integer")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def resumed_offset(header: str | None, total: int, fallback: Exception) -> int:
|
|
242
|
+
"""Read an Upload-Offset the server reported, or re-raise.
|
|
243
|
+
|
|
244
|
+
A server that answers with an offset past the end of the file, or with
|
|
245
|
+
something unparseable, cannot be resumed from safely -- continuing would
|
|
246
|
+
either skip bytes or corrupt the video.
|
|
247
|
+
"""
|
|
248
|
+
try:
|
|
249
|
+
offset = int(header or "")
|
|
250
|
+
except ValueError:
|
|
251
|
+
raise fallback from None
|
|
252
|
+
if offset < 0 or offset > total:
|
|
253
|
+
raise fallback
|
|
254
|
+
return offset
|
|
@@ -4,8 +4,9 @@ import asyncio
|
|
|
4
4
|
import builtins
|
|
5
5
|
import mimetypes
|
|
6
6
|
import os
|
|
7
|
-
from collections.abc import AsyncIterable, AsyncIterator, Mapping
|
|
7
|
+
from collections.abc import AsyncIterable, AsyncIterator, Callable, Mapping
|
|
8
8
|
from contextlib import suppress
|
|
9
|
+
from pathlib import Path
|
|
9
10
|
from types import TracebackType
|
|
10
11
|
from typing import Any, BinaryIO, TypeVar, cast
|
|
11
12
|
|
|
@@ -14,16 +15,22 @@ import httpx
|
|
|
14
15
|
from ._shared import (
|
|
15
16
|
DEFAULT_BASE_URL,
|
|
16
17
|
DEFAULT_CONTENT_TYPE,
|
|
18
|
+
DEFAULT_VIDEO_CHUNK_SIZE,
|
|
19
|
+
MAX_VIDEO_CHUNK_RETRIES,
|
|
17
20
|
UploadSource,
|
|
18
21
|
api_url,
|
|
19
22
|
image_url,
|
|
20
23
|
parse_client_session,
|
|
24
|
+
parse_folder_list,
|
|
21
25
|
query_string,
|
|
22
26
|
read_exact,
|
|
23
27
|
resolve_upload_source,
|
|
28
|
+
resumed_offset,
|
|
24
29
|
segment,
|
|
25
30
|
sorted_parts,
|
|
31
|
+
tus_metadata,
|
|
26
32
|
upload_payload,
|
|
33
|
+
validate_chunk_size,
|
|
27
34
|
validate_size,
|
|
28
35
|
)
|
|
29
36
|
from .errors import ArkError, error_from_response, invalid_argument, network_error, upload_error
|
|
@@ -375,12 +382,7 @@ class AsyncFolders:
|
|
|
375
382
|
"GET",
|
|
376
383
|
f"/folders{query_string({'parentId': parent_id})}",
|
|
377
384
|
)
|
|
378
|
-
|
|
379
|
-
return tuple(
|
|
380
|
-
ArkFolder.from_dict(item)
|
|
381
|
-
for item in (raw_data if isinstance(raw_data, list) else [])
|
|
382
|
-
if isinstance(item, Mapping)
|
|
383
|
-
)
|
|
385
|
+
return tuple(ArkFolder.from_dict(item) for item in parse_folder_list(value))
|
|
384
386
|
|
|
385
387
|
async def create(self, name: str, *, parent_id: str | None = None) -> ArkFolder:
|
|
386
388
|
payload: dict[str, Any] = {"name": name}
|
|
@@ -525,6 +527,138 @@ class AsyncStreams:
|
|
|
525
527
|
suffix = query_string({"appId": app_id})
|
|
526
528
|
await self._ark._request("DELETE", f"/streams/{segment(stream_id)}{suffix}")
|
|
527
529
|
|
|
530
|
+
async def upload(
|
|
531
|
+
self,
|
|
532
|
+
source: str | Path | BinaryIO,
|
|
533
|
+
*,
|
|
534
|
+
title: str | None = None,
|
|
535
|
+
size: int | None = None,
|
|
536
|
+
filename: str | None = None,
|
|
537
|
+
content_type: str | None = None,
|
|
538
|
+
app_id: str | None = None,
|
|
539
|
+
collection_id: str | None = None,
|
|
540
|
+
chunk_size: int = DEFAULT_VIDEO_CHUNK_SIZE,
|
|
541
|
+
on_progress: Callable[[int, int], None] | None = None,
|
|
542
|
+
) -> ArkStream:
|
|
543
|
+
"""Create a stream and upload the video, resuming across failures.
|
|
544
|
+
|
|
545
|
+
The async twin of `Streams.upload`. See that method for why this exists
|
|
546
|
+
rather than leaving TUS to the caller.
|
|
547
|
+
"""
|
|
548
|
+
validate_chunk_size(chunk_size)
|
|
549
|
+
resolved = resolve_upload_source(
|
|
550
|
+
source,
|
|
551
|
+
size=size,
|
|
552
|
+
filename=filename,
|
|
553
|
+
content_type=content_type or "video/mp4",
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
created = await self.create(
|
|
557
|
+
title or Path(resolved.filename).stem,
|
|
558
|
+
resolved.size,
|
|
559
|
+
app_id=app_id,
|
|
560
|
+
collection_id=collection_id,
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
upload_url = await self._create_tus_upload(created.upload.endpoint, resolved)
|
|
564
|
+
handle = resolved.path.open("rb") if resolved.path is not None else resolved.stream
|
|
565
|
+
try:
|
|
566
|
+
await self._send_chunks(
|
|
567
|
+
upload_url, handle, resolved.size, chunk_size, on_progress
|
|
568
|
+
)
|
|
569
|
+
finally:
|
|
570
|
+
if resolved.path is not None:
|
|
571
|
+
handle.close()
|
|
572
|
+
return created.stream
|
|
573
|
+
|
|
574
|
+
async def _create_tus_upload(self, endpoint: str, resolved: UploadSource) -> str:
|
|
575
|
+
url = self._ark._url(endpoint) if endpoint.startswith("/") else endpoint
|
|
576
|
+
try:
|
|
577
|
+
response = await self._ark._client.request(
|
|
578
|
+
"POST",
|
|
579
|
+
url,
|
|
580
|
+
headers={
|
|
581
|
+
"authorization": f"Bearer {self._ark._token}",
|
|
582
|
+
"Tus-Resumable": "1.0.0",
|
|
583
|
+
"Upload-Length": str(resolved.size),
|
|
584
|
+
"Upload-Metadata": (
|
|
585
|
+
f"filename {tus_metadata(resolved.filename)},"
|
|
586
|
+
f"filetype {tus_metadata(resolved.content_type)}"
|
|
587
|
+
),
|
|
588
|
+
},
|
|
589
|
+
)
|
|
590
|
+
except httpx.HTTPError as error:
|
|
591
|
+
raise network_error(error) from error
|
|
592
|
+
if response.is_error:
|
|
593
|
+
raise error_from_response(response)
|
|
594
|
+
|
|
595
|
+
location = response.headers.get("location")
|
|
596
|
+
if not location:
|
|
597
|
+
raise ArkError("INTERNAL_ERROR", "Ark did not return an upload location")
|
|
598
|
+
return str(httpx.URL(url).join(location))
|
|
599
|
+
|
|
600
|
+
async def _send_chunks(
|
|
601
|
+
self,
|
|
602
|
+
upload_url: str,
|
|
603
|
+
handle: BinaryIO,
|
|
604
|
+
total: int,
|
|
605
|
+
chunk_size: int,
|
|
606
|
+
on_progress: Callable[[int, int], None] | None,
|
|
607
|
+
) -> None:
|
|
608
|
+
offset = 0
|
|
609
|
+
retries = 0
|
|
610
|
+
while offset < total:
|
|
611
|
+
handle.seek(offset)
|
|
612
|
+
chunk = handle.read(min(chunk_size, total - offset))
|
|
613
|
+
if not chunk:
|
|
614
|
+
raise ArkError(
|
|
615
|
+
"INVALID_ARGUMENT",
|
|
616
|
+
"The video source ended before the declared size was reached",
|
|
617
|
+
)
|
|
618
|
+
try:
|
|
619
|
+
response = await self._ark._client.request(
|
|
620
|
+
"PATCH",
|
|
621
|
+
upload_url,
|
|
622
|
+
headers={
|
|
623
|
+
"authorization": f"Bearer {self._ark._token}",
|
|
624
|
+
"Tus-Resumable": "1.0.0",
|
|
625
|
+
"Upload-Offset": str(offset),
|
|
626
|
+
"Content-Type": "application/offset+octet-stream",
|
|
627
|
+
},
|
|
628
|
+
content=chunk,
|
|
629
|
+
)
|
|
630
|
+
if response.is_error:
|
|
631
|
+
raise error_from_response(response)
|
|
632
|
+
acknowledged = response.headers.get("upload-offset")
|
|
633
|
+
try:
|
|
634
|
+
offset = max(offset + len(chunk), int(acknowledged or ""))
|
|
635
|
+
except ValueError:
|
|
636
|
+
offset += len(chunk)
|
|
637
|
+
retries = 0
|
|
638
|
+
if on_progress is not None:
|
|
639
|
+
on_progress(offset, total)
|
|
640
|
+
except ArkError as error:
|
|
641
|
+
if retries >= MAX_VIDEO_CHUNK_RETRIES:
|
|
642
|
+
raise
|
|
643
|
+
retries += 1
|
|
644
|
+
offset = await self._head_offset(upload_url, total, error)
|
|
645
|
+
|
|
646
|
+
async def _head_offset(self, upload_url: str, total: int, fallback: ArkError) -> int:
|
|
647
|
+
try:
|
|
648
|
+
head = await self._ark._client.request(
|
|
649
|
+
"HEAD",
|
|
650
|
+
upload_url,
|
|
651
|
+
headers={
|
|
652
|
+
"authorization": f"Bearer {self._ark._token}",
|
|
653
|
+
"Tus-Resumable": "1.0.0",
|
|
654
|
+
},
|
|
655
|
+
)
|
|
656
|
+
except httpx.HTTPError:
|
|
657
|
+
raise fallback from None
|
|
658
|
+
if head.is_error:
|
|
659
|
+
raise fallback
|
|
660
|
+
return resumed_offset(head.headers.get("upload-offset"), total, fallback)
|
|
661
|
+
|
|
528
662
|
|
|
529
663
|
def _as_async_iterable(source: AsyncSource) -> AsyncIterator[bytes] | None:
|
|
530
664
|
method = getattr(source, "__aiter__", None)
|
|
@@ -19,6 +19,11 @@ class ArkError(Exception):
|
|
|
19
19
|
) -> None:
|
|
20
20
|
super().__init__(message)
|
|
21
21
|
self.code = code
|
|
22
|
+
# Kept as an attribute, not just handed to Exception. The README
|
|
23
|
+
# documents `error.message`, and only `str(error)` actually worked --
|
|
24
|
+
# so every caller following the docs hit an AttributeError inside
|
|
25
|
+
# their own error handler, which is the worst possible place for one.
|
|
26
|
+
self.message = message
|
|
22
27
|
self.status = status
|
|
23
28
|
self.request_id = request_id
|
|
24
29
|
self.details = details
|
|
@@ -83,5 +88,17 @@ def invalid_argument(message: str) -> ArkError:
|
|
|
83
88
|
return ArkError("INVALID_ARGUMENT", message)
|
|
84
89
|
|
|
85
90
|
|
|
91
|
+
def invalid_response(message: str) -> ArkError:
|
|
92
|
+
"""A 2xx body the SDK could not make sense of.
|
|
93
|
+
|
|
94
|
+
Coercing an unrecognised shape to an empty list is what turned a one-line
|
|
95
|
+
parsing bug into a workflow that could never succeed: `list()` reported no
|
|
96
|
+
folders, `create()` then refused because the folder was already there, and
|
|
97
|
+
nothing anywhere raised. Failing loudly here means the next such mismatch
|
|
98
|
+
is a stack trace pointing at the response, not a silent wrong answer.
|
|
99
|
+
"""
|
|
100
|
+
return ArkError("INVALID_RESPONSE", message)
|
|
101
|
+
|
|
102
|
+
|
|
86
103
|
def _optional_string(value: object) -> str | None:
|
|
87
104
|
return value if isinstance(value, str) else None
|
|
@@ -101,7 +101,15 @@ StreamStatus = Literal["created", "uploading", "processing", "ready", "failed"]
|
|
|
101
101
|
|
|
102
102
|
@dataclass(frozen=True, slots=True)
|
|
103
103
|
class ArkStream:
|
|
104
|
-
"""A video managed by Ark Streams.
|
|
104
|
+
"""A video managed by Ark Streams.
|
|
105
|
+
|
|
106
|
+
``hls_url``, ``thumbnail_url`` and ``embed_url`` are None until ``status``
|
|
107
|
+
is ``"ready"``: an encode takes time, and returning a URL that 404s would be
|
|
108
|
+
worse than reporting that it is unfinished. Poll ``streams.get`` until then.
|
|
109
|
+
|
|
110
|
+
``embed_url`` is an Ark-hosted player page -- put it straight in an iframe.
|
|
111
|
+
Playback is signed server-side per viewer, so it carries no credential.
|
|
112
|
+
"""
|
|
105
113
|
|
|
106
114
|
id: str
|
|
107
115
|
title: str
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import builtins
|
|
4
|
-
from collections.abc import Mapping
|
|
4
|
+
from collections.abc import Callable, Mapping
|
|
5
5
|
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
|
|
6
6
|
from contextlib import suppress
|
|
7
7
|
from pathlib import Path
|
|
@@ -12,6 +12,8 @@ import httpx
|
|
|
12
12
|
|
|
13
13
|
from ._shared import (
|
|
14
14
|
DEFAULT_BASE_URL,
|
|
15
|
+
DEFAULT_VIDEO_CHUNK_SIZE,
|
|
16
|
+
MAX_VIDEO_CHUNK_RETRIES,
|
|
15
17
|
UploadSource,
|
|
16
18
|
api_url,
|
|
17
19
|
ensure_stream_complete,
|
|
@@ -19,12 +21,16 @@ from ._shared import (
|
|
|
19
21
|
iter_exact,
|
|
20
22
|
iter_file_range,
|
|
21
23
|
parse_client_session,
|
|
24
|
+
parse_folder_list,
|
|
22
25
|
query_string,
|
|
23
26
|
read_exact,
|
|
24
27
|
resolve_upload_source,
|
|
28
|
+
resumed_offset,
|
|
25
29
|
segment,
|
|
26
30
|
sorted_parts,
|
|
31
|
+
tus_metadata,
|
|
27
32
|
upload_payload,
|
|
33
|
+
validate_chunk_size,
|
|
28
34
|
)
|
|
29
35
|
from .errors import ArkError, error_from_response, network_error, upload_error
|
|
30
36
|
from .models import (
|
|
@@ -334,12 +340,7 @@ class Folders:
|
|
|
334
340
|
|
|
335
341
|
def list(self, *, parent_id: str | None = None) -> tuple[ArkFolder, ...]:
|
|
336
342
|
value = self._ark._request("GET", f"/folders{query_string({'parentId': parent_id})}")
|
|
337
|
-
|
|
338
|
-
return tuple(
|
|
339
|
-
ArkFolder.from_dict(item)
|
|
340
|
-
for item in (raw_data if isinstance(raw_data, list) else [])
|
|
341
|
-
if isinstance(item, Mapping)
|
|
342
|
-
)
|
|
343
|
+
return tuple(ArkFolder.from_dict(item) for item in parse_folder_list(value))
|
|
343
344
|
|
|
344
345
|
def create(self, name: str, *, parent_id: str | None = None) -> ArkFolder:
|
|
345
346
|
payload: dict[str, Any] = {"name": name}
|
|
@@ -479,3 +480,145 @@ class Streams:
|
|
|
479
480
|
def delete(self, stream_id: str, *, app_id: str | None = None) -> None:
|
|
480
481
|
suffix = query_string({"appId": app_id})
|
|
481
482
|
self._ark._request("DELETE", f"/streams/{segment(stream_id)}{suffix}")
|
|
483
|
+
|
|
484
|
+
def upload(
|
|
485
|
+
self,
|
|
486
|
+
source: str | Path | BinaryIO,
|
|
487
|
+
*,
|
|
488
|
+
title: str | None = None,
|
|
489
|
+
size: int | None = None,
|
|
490
|
+
filename: str | None = None,
|
|
491
|
+
content_type: str | None = None,
|
|
492
|
+
app_id: str | None = None,
|
|
493
|
+
collection_id: str | None = None,
|
|
494
|
+
chunk_size: int = DEFAULT_VIDEO_CHUNK_SIZE,
|
|
495
|
+
on_progress: Callable[[int, int], None] | None = None,
|
|
496
|
+
) -> ArkStream:
|
|
497
|
+
"""Create a stream and upload the video, resuming across failures.
|
|
498
|
+
|
|
499
|
+
`create()` only returns a ticket; the bytes still have to be sent, and
|
|
500
|
+
doing that correctly means speaking TUS. Without this a Python caller
|
|
501
|
+
had to implement the protocol themselves, which is the one part of
|
|
502
|
+
Streams they should not have to think about.
|
|
503
|
+
|
|
504
|
+
Returns the stream as it was at creation. Encoding continues afterwards,
|
|
505
|
+
so poll `get()` for `status == "ready"` before using the playback URLs.
|
|
506
|
+
"""
|
|
507
|
+
validate_chunk_size(chunk_size)
|
|
508
|
+
resolved = resolve_upload_source(
|
|
509
|
+
source,
|
|
510
|
+
size=size,
|
|
511
|
+
filename=filename,
|
|
512
|
+
content_type=content_type or "video/mp4",
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
created = self.create(
|
|
516
|
+
title or Path(resolved.filename).stem,
|
|
517
|
+
resolved.size,
|
|
518
|
+
app_id=app_id,
|
|
519
|
+
collection_id=collection_id,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
upload_url = self._create_tus_upload(created.upload.endpoint, resolved)
|
|
523
|
+
handle = resolved.path.open("rb") if resolved.path is not None else resolved.stream
|
|
524
|
+
try:
|
|
525
|
+
self._send_chunks(upload_url, handle, resolved.size, chunk_size, on_progress)
|
|
526
|
+
finally:
|
|
527
|
+
if resolved.path is not None:
|
|
528
|
+
handle.close()
|
|
529
|
+
return created.stream
|
|
530
|
+
|
|
531
|
+
def _create_tus_upload(self, endpoint: str, resolved: UploadSource) -> str:
|
|
532
|
+
"""Open the TUS upload and return the URL to send chunks to."""
|
|
533
|
+
url = self._ark._url(endpoint) if endpoint.startswith("/") else endpoint
|
|
534
|
+
try:
|
|
535
|
+
response = self._ark._client.request(
|
|
536
|
+
"POST",
|
|
537
|
+
url,
|
|
538
|
+
headers={
|
|
539
|
+
"authorization": f"Bearer {self._ark._token}",
|
|
540
|
+
"Tus-Resumable": "1.0.0",
|
|
541
|
+
"Upload-Length": str(resolved.size),
|
|
542
|
+
"Upload-Metadata": (
|
|
543
|
+
f"filename {tus_metadata(resolved.filename)},"
|
|
544
|
+
f"filetype {tus_metadata(resolved.content_type)}"
|
|
545
|
+
),
|
|
546
|
+
},
|
|
547
|
+
)
|
|
548
|
+
except httpx.HTTPError as error:
|
|
549
|
+
raise network_error(error) from error
|
|
550
|
+
if response.is_error:
|
|
551
|
+
raise error_from_response(response)
|
|
552
|
+
|
|
553
|
+
location = response.headers.get("location")
|
|
554
|
+
if not location:
|
|
555
|
+
raise ArkError("INTERNAL_ERROR", "Ark did not return an upload location")
|
|
556
|
+
# The server may answer with a relative location.
|
|
557
|
+
return str(httpx.URL(url).join(location))
|
|
558
|
+
|
|
559
|
+
def _send_chunks(
|
|
560
|
+
self,
|
|
561
|
+
upload_url: str,
|
|
562
|
+
handle: BinaryIO,
|
|
563
|
+
total: int,
|
|
564
|
+
chunk_size: int,
|
|
565
|
+
on_progress: Callable[[int, int], None] | None,
|
|
566
|
+
) -> None:
|
|
567
|
+
offset = 0
|
|
568
|
+
retries = 0
|
|
569
|
+
while offset < total:
|
|
570
|
+
handle.seek(offset)
|
|
571
|
+
chunk = handle.read(min(chunk_size, total - offset))
|
|
572
|
+
if not chunk:
|
|
573
|
+
raise ArkError(
|
|
574
|
+
"INVALID_ARGUMENT",
|
|
575
|
+
"The video source ended before the declared size was reached",
|
|
576
|
+
)
|
|
577
|
+
try:
|
|
578
|
+
response = self._ark._client.request(
|
|
579
|
+
"PATCH",
|
|
580
|
+
upload_url,
|
|
581
|
+
headers={
|
|
582
|
+
"authorization": f"Bearer {self._ark._token}",
|
|
583
|
+
"Tus-Resumable": "1.0.0",
|
|
584
|
+
"Upload-Offset": str(offset),
|
|
585
|
+
"Content-Type": "application/offset+octet-stream",
|
|
586
|
+
},
|
|
587
|
+
content=chunk,
|
|
588
|
+
)
|
|
589
|
+
if response.is_error:
|
|
590
|
+
raise error_from_response(response)
|
|
591
|
+
# Trust the server's acknowledged offset over our own arithmetic:
|
|
592
|
+
# it is the only party that knows what actually landed.
|
|
593
|
+
acknowledged = response.headers.get("upload-offset")
|
|
594
|
+
try:
|
|
595
|
+
offset = max(offset + len(chunk), int(acknowledged or ""))
|
|
596
|
+
except ValueError:
|
|
597
|
+
offset += len(chunk)
|
|
598
|
+
retries = 0
|
|
599
|
+
if on_progress is not None:
|
|
600
|
+
on_progress(offset, total)
|
|
601
|
+
except ArkError as error:
|
|
602
|
+
if retries >= MAX_VIDEO_CHUNK_RETRIES:
|
|
603
|
+
raise
|
|
604
|
+
retries += 1
|
|
605
|
+
# Ask what the server holds rather than assuming the chunk was
|
|
606
|
+
# lost -- a failure after the bytes landed would otherwise send
|
|
607
|
+
# them twice.
|
|
608
|
+
offset = self._head_offset(upload_url, total, error)
|
|
609
|
+
|
|
610
|
+
def _head_offset(self, upload_url: str, total: int, fallback: ArkError) -> int:
|
|
611
|
+
try:
|
|
612
|
+
head = self._ark._client.request(
|
|
613
|
+
"HEAD",
|
|
614
|
+
upload_url,
|
|
615
|
+
headers={
|
|
616
|
+
"authorization": f"Bearer {self._ark._token}",
|
|
617
|
+
"Tus-Resumable": "1.0.0",
|
|
618
|
+
},
|
|
619
|
+
)
|
|
620
|
+
except httpx.HTTPError:
|
|
621
|
+
raise fallback from None
|
|
622
|
+
if head.is_error:
|
|
623
|
+
raise fallback
|
|
624
|
+
return resumed_offset(head.headers.get("upload-offset"), total, fallback)
|
|
@@ -199,3 +199,68 @@ async def test_async_upload_requires_stream_metadata() -> None:
|
|
|
199
199
|
with pytest.raises(ArkError, match="filename is required"):
|
|
200
200
|
await ark.files.upload(source(), size=4)
|
|
201
201
|
await client.aclose()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@pytest.mark.asyncio
|
|
205
|
+
async def test_async_folders_list_reads_the_api_envelope() -> None:
|
|
206
|
+
"""The async client had no folder coverage at all, which is why it carried
|
|
207
|
+
the same 'data' vs 'folders' bug as the sync client and nothing caught it.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
211
|
+
return json_response(
|
|
212
|
+
{
|
|
213
|
+
"folders": [
|
|
214
|
+
{"id": "folder-1", "name": "Media", "parentId": None},
|
|
215
|
+
{"id": "folder-2", "name": "Docs", "parentId": "folder-1"},
|
|
216
|
+
],
|
|
217
|
+
"pagination": {"page": 1, "limit": 50, "total": 2, "pages": 1},
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
client = async_client_for(handler)
|
|
222
|
+
ark = AsyncArk("token", client=client)
|
|
223
|
+
folders = await ark.folders.list()
|
|
224
|
+
assert [folder.name for folder in folders] == ["Media", "Docs"]
|
|
225
|
+
await client.aclose()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@pytest.mark.asyncio
|
|
229
|
+
async def test_async_folders_list_still_accepts_a_data_envelope() -> None:
|
|
230
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
231
|
+
return json_response({"data": [{"id": "f1", "name": "Legacy", "parentId": None}]})
|
|
232
|
+
|
|
233
|
+
client = async_client_for(handler)
|
|
234
|
+
ark = AsyncArk("token", client=client)
|
|
235
|
+
assert [folder.name for folder in await ark.folders.list()] == ["Legacy"]
|
|
236
|
+
await client.aclose()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@pytest.mark.asyncio
|
|
240
|
+
async def test_async_folders_list_rejects_an_unrecognised_body() -> None:
|
|
241
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
242
|
+
return json_response({"unexpected": []})
|
|
243
|
+
|
|
244
|
+
client = async_client_for(handler)
|
|
245
|
+
ark = AsyncArk("token", client=client)
|
|
246
|
+
with pytest.raises(ArkError) as caught:
|
|
247
|
+
await ark.folders.list()
|
|
248
|
+
assert caught.value.code == "INVALID_RESPONSE"
|
|
249
|
+
await client.aclose()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@pytest.mark.asyncio
|
|
253
|
+
async def test_async_folders_list_passes_parent_id_through() -> None:
|
|
254
|
+
"""Nested resolution walks children level by level, so parentId has to
|
|
255
|
+
reach the query string for anything below the root to be listable."""
|
|
256
|
+
urls: list[str] = []
|
|
257
|
+
|
|
258
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
259
|
+
urls.append(str(request.url))
|
|
260
|
+
return json_response({"folders": [], "pagination": {"total": 0}})
|
|
261
|
+
|
|
262
|
+
client = async_client_for(handler)
|
|
263
|
+
ark = AsyncArk("token", client=client)
|
|
264
|
+
await ark.folders.list(parent_id="folder-1")
|
|
265
|
+
assert "parentId=folder-1" in urls[0]
|
|
266
|
+
await client.aclose()
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
from conftest import json_response
|
|
8
|
+
|
|
9
|
+
from ark_py import Ark, ArkError
|
|
10
|
+
|
|
11
|
+
STREAM = {
|
|
12
|
+
"id": "stream-1",
|
|
13
|
+
"title": "clip",
|
|
14
|
+
"status": "created",
|
|
15
|
+
"encodeProgress": 0,
|
|
16
|
+
"durationSeconds": 0,
|
|
17
|
+
"width": 0,
|
|
18
|
+
"height": 0,
|
|
19
|
+
"size": 9,
|
|
20
|
+
"thumbnailUrl": None,
|
|
21
|
+
"hlsUrl": None,
|
|
22
|
+
"embedUrl": None,
|
|
23
|
+
"createdAt": "2026-09-03T00:00:00.000Z",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TusServer:
|
|
28
|
+
"""A TUS endpoint that records what it was sent.
|
|
29
|
+
|
|
30
|
+
`fail_at_offset` makes exactly one PATCH fail *after* accepting the bytes,
|
|
31
|
+
which is the case worth testing: a client that assumes a failed request
|
|
32
|
+
transferred nothing would resend those bytes and corrupt the video.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, total: int, *, fail_at_offset: int | None = None) -> None:
|
|
36
|
+
self.total = total
|
|
37
|
+
self.fail_at_offset = fail_at_offset
|
|
38
|
+
self.received = bytearray()
|
|
39
|
+
self.patches: list[int] = []
|
|
40
|
+
self.head_calls = 0
|
|
41
|
+
|
|
42
|
+
def handler(self, request: httpx.Request) -> httpx.Response:
|
|
43
|
+
if request.url.path.endswith("/streams") and request.method == "POST":
|
|
44
|
+
return json_response({"stream": STREAM, "upload": {"endpoint": "/streams/stream-1/upload"}})
|
|
45
|
+
|
|
46
|
+
if request.url.path == "/api/v2/streams/stream-1/upload" and request.method == "POST":
|
|
47
|
+
assert request.headers["upload-length"] == str(self.total)
|
|
48
|
+
assert "filename" in request.headers["upload-metadata"]
|
|
49
|
+
return httpx.Response(201, headers={"location": "/api/v2/streams/stream-1/upload/abc"})
|
|
50
|
+
|
|
51
|
+
if request.url.path.endswith("/upload/abc") and request.method == "HEAD":
|
|
52
|
+
self.head_calls += 1
|
|
53
|
+
return httpx.Response(200, headers={"upload-offset": str(len(self.received))})
|
|
54
|
+
|
|
55
|
+
if request.url.path.endswith("/upload/abc") and request.method == "PATCH":
|
|
56
|
+
offset = int(request.headers["upload-offset"])
|
|
57
|
+
self.patches.append(offset)
|
|
58
|
+
body = request.read()
|
|
59
|
+
# The server only accepts a write that starts where it left off.
|
|
60
|
+
assert offset == len(self.received), "client resent or skipped bytes"
|
|
61
|
+
self.received.extend(body)
|
|
62
|
+
if self.fail_at_offset is not None and offset == self.fail_at_offset:
|
|
63
|
+
self.fail_at_offset = None
|
|
64
|
+
return httpx.Response(500, json={"error": "flaky"})
|
|
65
|
+
return httpx.Response(204, headers={"upload-offset": str(len(self.received))})
|
|
66
|
+
|
|
67
|
+
raise AssertionError(f"unexpected {request.method} {request.url}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def client_for(server: TusServer) -> Ark:
|
|
71
|
+
return Ark(
|
|
72
|
+
"token",
|
|
73
|
+
base_url="https://ark.test",
|
|
74
|
+
client=httpx.Client(transport=httpx.MockTransport(server.handler)),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_uploads_a_file_in_chunks(tmp_path: Path) -> None:
|
|
79
|
+
path = tmp_path / "clip.mp4"
|
|
80
|
+
path.write_bytes(b"abcdefghi")
|
|
81
|
+
server = TusServer(9)
|
|
82
|
+
|
|
83
|
+
stream = client_for(server).streams.upload(path, chunk_size=4)
|
|
84
|
+
|
|
85
|
+
assert stream.id == "stream-1"
|
|
86
|
+
assert bytes(server.received) == b"abcdefghi"
|
|
87
|
+
assert server.patches == [0, 4, 8]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_resumes_from_the_server_offset_after_a_failure(tmp_path: Path) -> None:
|
|
91
|
+
path = tmp_path / "clip.mp4"
|
|
92
|
+
path.write_bytes(b"abcdefghi")
|
|
93
|
+
# The failure lands after the server stored bytes 4-7.
|
|
94
|
+
server = TusServer(9, fail_at_offset=4)
|
|
95
|
+
|
|
96
|
+
client_for(server).streams.upload(path, chunk_size=4)
|
|
97
|
+
|
|
98
|
+
# The whole file arrives exactly once: the retry asked where to continue
|
|
99
|
+
# rather than resending the chunk that had in fact landed.
|
|
100
|
+
assert bytes(server.received) == b"abcdefghi"
|
|
101
|
+
assert server.head_calls == 1
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_reports_progress(tmp_path: Path) -> None:
|
|
105
|
+
path = tmp_path / "clip.mp4"
|
|
106
|
+
path.write_bytes(b"abcdefghi")
|
|
107
|
+
seen: list[tuple[int, int]] = []
|
|
108
|
+
|
|
109
|
+
client_for(TusServer(9)).streams.upload(
|
|
110
|
+
path, chunk_size=4, on_progress=lambda done, total: seen.append((done, total))
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
assert seen == [(4, 9), (8, 9), (9, 9)]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_derives_the_title_from_the_filename(tmp_path: Path) -> None:
|
|
117
|
+
path = tmp_path / "my-launch-video.mp4"
|
|
118
|
+
path.write_bytes(b"abc")
|
|
119
|
+
titles: list[str] = []
|
|
120
|
+
|
|
121
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
122
|
+
if request.url.path.endswith("/streams") and request.method == "POST":
|
|
123
|
+
import json as _json
|
|
124
|
+
|
|
125
|
+
titles.append(_json.loads(request.read())["title"])
|
|
126
|
+
return json_response({"stream": STREAM, "upload": {"endpoint": "/streams/stream-1/upload"}})
|
|
127
|
+
if request.method == "POST":
|
|
128
|
+
return httpx.Response(201, headers={"location": "/api/v2/streams/stream-1/upload/abc"})
|
|
129
|
+
return httpx.Response(204, headers={"upload-offset": "3"})
|
|
130
|
+
|
|
131
|
+
Ark(
|
|
132
|
+
"token",
|
|
133
|
+
base_url="https://ark.test",
|
|
134
|
+
client=httpx.Client(transport=httpx.MockTransport(handler)),
|
|
135
|
+
).streams.upload(path)
|
|
136
|
+
|
|
137
|
+
assert titles == ["my-launch-video"]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_rejects_an_invalid_chunk_size(tmp_path: Path) -> None:
|
|
141
|
+
path = tmp_path / "clip.mp4"
|
|
142
|
+
path.write_bytes(b"abc")
|
|
143
|
+
|
|
144
|
+
with pytest.raises(ArkError) as excinfo:
|
|
145
|
+
client_for(TusServer(3)).streams.upload(path, chunk_size=0)
|
|
146
|
+
|
|
147
|
+
assert excinfo.value.code == "INVALID_ARGUMENT"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@pytest.mark.asyncio
|
|
151
|
+
async def test_async_upload_matches_the_sync_client(tmp_path: Path) -> None:
|
|
152
|
+
"""The async client must transfer identically, not merely succeed."""
|
|
153
|
+
from ark_py import AsyncArk
|
|
154
|
+
|
|
155
|
+
path = tmp_path / "clip.mp4"
|
|
156
|
+
path.write_bytes(b"abcdefghi")
|
|
157
|
+
server = TusServer(9, fail_at_offset=4)
|
|
158
|
+
|
|
159
|
+
ark = AsyncArk(
|
|
160
|
+
"token",
|
|
161
|
+
base_url="https://ark.test",
|
|
162
|
+
client=httpx.AsyncClient(transport=httpx.MockTransport(server.handler)),
|
|
163
|
+
)
|
|
164
|
+
stream = await ark.streams.upload(path, chunk_size=4)
|
|
165
|
+
|
|
166
|
+
assert stream.id == "stream-1"
|
|
167
|
+
assert bytes(server.received) == b"abcdefghi"
|
|
168
|
+
assert server.head_calls == 1
|
|
@@ -55,7 +55,16 @@ def test_resources_models_images_and_errors() -> None:
|
|
|
55
55
|
if request.url.path.endswith("/files"):
|
|
56
56
|
return json_response({"data": [file_response(4)], "nextCursor": "next"})
|
|
57
57
|
if request.url.path.endswith("/folders"):
|
|
58
|
-
|
|
58
|
+
# The envelope the API actually sends. This mock previously
|
|
59
|
+
# returned {"data": ...}, matching the SDK's mistaken expectation
|
|
60
|
+
# rather than the server, so it passed while every real call
|
|
61
|
+
# returned nothing.
|
|
62
|
+
return json_response(
|
|
63
|
+
{
|
|
64
|
+
"folders": [{"id": "folder-1", "name": "Media", "parentId": None}],
|
|
65
|
+
"pagination": {"page": 1, "limit": 50, "total": 1, "pages": 1},
|
|
66
|
+
}
|
|
67
|
+
)
|
|
59
68
|
if request.url.path.endswith("/usage"):
|
|
60
69
|
return json_response(
|
|
61
70
|
{
|
|
@@ -284,3 +293,82 @@ def test_non_seekable_stream_requires_size_and_filename() -> None:
|
|
|
284
293
|
with pytest.raises(ArkError, match="filename is required"):
|
|
285
294
|
ark.files.upload(NonSeekable(b"data"), size=4)
|
|
286
295
|
ark._client.close()
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_folders_list_reads_the_shape_the_api_actually_returns() -> None:
|
|
299
|
+
"""GET /v2/folders returns {"folders": [...], "pagination": {...}}.
|
|
300
|
+
|
|
301
|
+
The SDK read "data" -- the key /v2/files uses -- so this returned an empty
|
|
302
|
+
tuple against every real workspace. Nothing raised: the parser's
|
|
303
|
+
isinstance guard turned the missing key into [], so callers that resolve a
|
|
304
|
+
folder by name saw "no such folder", called create(), and got back "a
|
|
305
|
+
folder with this name already exists". That contradiction is what made the
|
|
306
|
+
bug expensive to diagnose, and it shipped because the test mock returned
|
|
307
|
+
the same wrong shape the code expected.
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
311
|
+
return json_response(
|
|
312
|
+
{
|
|
313
|
+
"folders": [
|
|
314
|
+
{"id": "folder-1", "name": "Media", "parentId": None},
|
|
315
|
+
{"id": "folder-2", "name": "Docs", "parentId": None},
|
|
316
|
+
],
|
|
317
|
+
"pagination": {"page": 1, "limit": 50, "total": 2, "pages": 1},
|
|
318
|
+
}
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
client = client_for(handler)
|
|
322
|
+
ark = Ark("token", client=client)
|
|
323
|
+
folders = ark.folders.list()
|
|
324
|
+
assert [folder.name for folder in folders] == ["Media", "Docs"]
|
|
325
|
+
client.close()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def test_folders_list_still_accepts_a_data_envelope() -> None:
|
|
329
|
+
"""A deployment that has not been updated must keep working."""
|
|
330
|
+
|
|
331
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
332
|
+
return json_response({"data": [{"id": "f1", "name": "Legacy", "parentId": None}]})
|
|
333
|
+
|
|
334
|
+
client = client_for(handler)
|
|
335
|
+
ark = Ark("token", client=client)
|
|
336
|
+
assert [folder.name for folder in ark.folders.list()] == ["Legacy"]
|
|
337
|
+
client.close()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def test_folders_list_rejects_a_body_it_does_not_understand() -> None:
|
|
341
|
+
"""An unrecognised body must not be silently reported as "no folders".
|
|
342
|
+
|
|
343
|
+
Returning () for a response the SDK failed to parse is what turned a
|
|
344
|
+
parsing bug into a workflow that could never succeed.
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
348
|
+
return json_response({"unexpected": []})
|
|
349
|
+
|
|
350
|
+
client = client_for(handler)
|
|
351
|
+
ark = Ark("token", client=client)
|
|
352
|
+
with pytest.raises(ArkError) as caught:
|
|
353
|
+
ark.folders.list()
|
|
354
|
+
assert caught.value.code == "INVALID_RESPONSE"
|
|
355
|
+
client.close()
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def test_folders_list_distinguishes_empty_from_unparseable() -> None:
|
|
359
|
+
"""An genuinely empty folder list is still an empty tuple, not an error."""
|
|
360
|
+
|
|
361
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
362
|
+
return json_response({"folders": [], "pagination": {"total": 0}})
|
|
363
|
+
|
|
364
|
+
client = client_for(handler)
|
|
365
|
+
ark = Ark("token", client=client)
|
|
366
|
+
assert ark.folders.list() == ()
|
|
367
|
+
client.close()
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def test_ark_error_exposes_message() -> None:
|
|
371
|
+
"""The README documents `message`; only `str(error)` actually worked."""
|
|
372
|
+
error = ArkError("NOT_FOUND", "missing", status=404)
|
|
373
|
+
assert error.message == "missing"
|
|
374
|
+
assert str(error) == "missing"
|
|
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
|