nerdstack-ark 1.0.4__tar.gz → 1.0.6__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.6/CHANGELOG.md +49 -0
- {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.6}/PKG-INFO +1 -1
- {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.6}/pyproject.toml +1 -1
- {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.6}/src/ark_py/__init__.py +10 -1
- {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.6}/src/ark_py/_shared.py +29 -1
- {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.6}/src/ark_py/async_client.py +2 -6
- nerdstack_ark-1.0.6/src/ark_py/errors.py +143 -0
- {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.6}/src/ark_py/models.py +13 -1
- {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.6}/src/ark_py/sync.py +2 -6
- {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.6}/tests/test_async.py +65 -0
- nerdstack_ark-1.0.6/tests/test_edge_block.py +62 -0
- {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.6}/tests/test_sync.py +95 -2
- nerdstack_ark-1.0.4/src/ark_py/errors.py +0 -87
- {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.6}/.gitignore +0 -0
- {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.6}/LICENSE +0 -0
- {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.6}/README.md +0 -0
- {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.6}/examples/django/apps.py +0 -0
- {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.6}/examples/django/views.py +0 -0
- {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.6}/examples/fastapi/app.py +0 -0
- {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.6}/examples/flask/app.py +0 -0
- {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.6}/src/ark_py/py.typed +0 -0
- {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.6}/src/ark_py/s3.py +0 -0
- {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.6}/tests/conftest.py +0 -0
- {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.6}/tests/test_stream_upload.py +0 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.5
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `ArkStream.hls_expires_at` reports when `hls_url` and `thumbnail_url` stop
|
|
8
|
+
working, so a caller can refresh before playback breaks instead of guessing a
|
|
9
|
+
lifetime. Absent on older deployments, where it parses as `None`.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Documented that `hls_url` and `thumbnail_url` are signed and expire within
|
|
14
|
+
the hour, and must not be stored. `ArkFile.url` is a permanent CDN path, and
|
|
15
|
+
the identical field names made the opposite behaviour easy to miss: storing a
|
|
16
|
+
playback URL yields a link that works in testing and is dead when a user
|
|
17
|
+
opens it. Persist `id` and resolve playback on demand, or use `embed_url`,
|
|
18
|
+
which carries no credential and does not expire.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- `folders.list()` and `AsyncFolders.list()` returned an empty tuple against
|
|
23
|
+
every real workspace. `GET /api/v2/folders` returns the array under
|
|
24
|
+
`folders`, but the SDK read `data` — the key `/api/v2/files` uses. The
|
|
25
|
+
missing key was silently coerced to an empty list, so callers that resolve a
|
|
26
|
+
folder by name saw "no such folder", called `create()`, and got back
|
|
27
|
+
"A folder with this name already exists here". Nesting was unusable for the
|
|
28
|
+
same reason, since resolving `a/b/c` lists children at each level.
|
|
29
|
+
|
|
30
|
+
Both envelopes are now accepted, so an older deployment keeps working.
|
|
31
|
+
|
|
32
|
+
- A folder-list body the SDK cannot parse now raises `ArkError` with code
|
|
33
|
+
`INVALID_RESPONSE` instead of being reported as an empty list. The silent
|
|
34
|
+
empty result is what made the bug above expensive to diagnose: `list()` and
|
|
35
|
+
`create()` reported opposite things and neither raised.
|
|
36
|
+
|
|
37
|
+
- `ArkError` now exposes `message`. It was documented and passed to
|
|
38
|
+
`Exception.__init__`, but never stored, so `error.message` raised
|
|
39
|
+
`AttributeError` inside callers' own error handlers. `str(error)` was the
|
|
40
|
+
only thing that worked.
|
|
41
|
+
|
|
42
|
+
- `ark_py.__version__` read `1.0.0` while the package was on 1.0.4. It is now
|
|
43
|
+
read from the installed distribution metadata, so it cannot drift again.
|
|
44
|
+
|
|
45
|
+
### Internal
|
|
46
|
+
|
|
47
|
+
- Folder parsing moved to `_shared.parse_folder_list`. The identical code was
|
|
48
|
+
duplicated in the sync and async clients, which is why one bug needed fixing
|
|
49
|
+
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.6
|
|
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
|
|
@@ -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"
|
|
@@ -9,7 +9,7 @@ from pathlib import Path
|
|
|
9
9
|
from typing import Any, BinaryIO
|
|
10
10
|
from urllib.parse import quote, urlencode
|
|
11
11
|
|
|
12
|
-
from .errors import invalid_argument
|
|
12
|
+
from .errors import invalid_argument, invalid_response
|
|
13
13
|
from .models import ClientSession, ImageOptions
|
|
14
14
|
|
|
15
15
|
DEFAULT_BASE_URL = "https://ark.nerdstackgrp.com"
|
|
@@ -42,6 +42,34 @@ def image_url(base_url: str, version: str, asset_id: str, options: ImageOptions)
|
|
|
42
42
|
return api_url(base_url, version, f"/assets/{segment(asset_id)}/image{suffix}")
|
|
43
43
|
|
|
44
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
|
+
|
|
45
73
|
def parse_client_session(value: Mapping[str, Any]) -> ClientSession:
|
|
46
74
|
raw_scopes = value.get("scopes")
|
|
47
75
|
scopes = tuple(str(scope) for scope in raw_scopes) if isinstance(raw_scopes, list) else ()
|
|
@@ -21,6 +21,7 @@ from ._shared import (
|
|
|
21
21
|
api_url,
|
|
22
22
|
image_url,
|
|
23
23
|
parse_client_session,
|
|
24
|
+
parse_folder_list,
|
|
24
25
|
query_string,
|
|
25
26
|
read_exact,
|
|
26
27
|
resolve_upload_source,
|
|
@@ -381,12 +382,7 @@ class AsyncFolders:
|
|
|
381
382
|
"GET",
|
|
382
383
|
f"/folders{query_string({'parentId': parent_id})}",
|
|
383
384
|
)
|
|
384
|
-
|
|
385
|
-
return tuple(
|
|
386
|
-
ArkFolder.from_dict(item)
|
|
387
|
-
for item in (raw_data if isinstance(raw_data, list) else [])
|
|
388
|
-
if isinstance(item, Mapping)
|
|
389
|
-
)
|
|
385
|
+
return tuple(ArkFolder.from_dict(item) for item in parse_folder_list(value))
|
|
390
386
|
|
|
391
387
|
async def create(self, name: str, *, parent_id: str | None = None) -> ArkFolder:
|
|
392
388
|
payload: dict[str, Any] = {"name": name}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ArkError(Exception):
|
|
9
|
+
"""A normalized Ark REST or upload error."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
code: str,
|
|
14
|
+
message: str,
|
|
15
|
+
*,
|
|
16
|
+
status: int | None = None,
|
|
17
|
+
request_id: str | None = None,
|
|
18
|
+
details: dict[str, Any] | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
super().__init__(message)
|
|
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
|
|
27
|
+
self.status = status
|
|
28
|
+
self.request_id = request_id
|
|
29
|
+
self.details = details
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def retryable(self) -> bool:
|
|
33
|
+
# BLOCKED_BY_EDGE is retryable: the request never reached Ark, and a
|
|
34
|
+
# challenge can clear on a retry or once the block is lifted.
|
|
35
|
+
return self.code in {"NETWORK_ERROR", "RATE_LIMITED", "BLOCKED_BY_EDGE"} or (
|
|
36
|
+
self.status is not None and self.status >= 500
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_edge_block(response: httpx.Response, parsed: bool) -> bool:
|
|
41
|
+
"""Whether a failure came from something in front of Ark rather than Ark.
|
|
42
|
+
|
|
43
|
+
The Ark API answers every request -- success or error -- with JSON, so an
|
|
44
|
+
HTML body on an error response cannot have come from Ark. In practice it is
|
|
45
|
+
a CDN or WAF challenge page: server-side SDK calls originate from
|
|
46
|
+
data-centre IP ranges, which bot protection scores as automated traffic.
|
|
47
|
+
|
|
48
|
+
Detected by content type rather than by matching challenge text, so this
|
|
49
|
+
holds for any interposed proxy and not just one vendor's wording.
|
|
50
|
+
"""
|
|
51
|
+
if parsed:
|
|
52
|
+
return False
|
|
53
|
+
if response.status_code not in {403, 503, 429}:
|
|
54
|
+
return False
|
|
55
|
+
return "text/html" in response.headers.get("content-type", "")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def error_from_response(response: httpx.Response) -> ArkError:
|
|
59
|
+
parsed = True
|
|
60
|
+
try:
|
|
61
|
+
body = response.json()
|
|
62
|
+
except ValueError:
|
|
63
|
+
body = {}
|
|
64
|
+
parsed = False
|
|
65
|
+
|
|
66
|
+
# Reported before the status mapping below, which would otherwise call this
|
|
67
|
+
# INSUFFICIENT_SCOPE and send the developer to audit a token that is fine.
|
|
68
|
+
if _is_edge_block(response, parsed):
|
|
69
|
+
ray = response.headers.get("cf-ray")
|
|
70
|
+
return ArkError(
|
|
71
|
+
"BLOCKED_BY_EDGE",
|
|
72
|
+
"The request was blocked by a network in front of Ark and never reached it. "
|
|
73
|
+
"This usually means bot protection challenged the call because it came from a "
|
|
74
|
+
"data-centre IP, which is where server-side code runs. "
|
|
75
|
+
"The site owner can allow it by exempting the API path from the challenge."
|
|
76
|
+
+ (f" Reference: cf-ray {ray}." if ray else ""),
|
|
77
|
+
status=response.status_code,
|
|
78
|
+
request_id=ray,
|
|
79
|
+
details={"cfRay": ray} if ray else None,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
raw_error = body.get("error") if isinstance(body, dict) else None
|
|
83
|
+
envelope = raw_error if isinstance(raw_error, dict) else {}
|
|
84
|
+
status_codes = {
|
|
85
|
+
400: "INVALID_ARGUMENT",
|
|
86
|
+
401: "UNAUTHORIZED",
|
|
87
|
+
402: "QUOTA_EXCEEDED",
|
|
88
|
+
403: "INSUFFICIENT_SCOPE",
|
|
89
|
+
404: "NOT_FOUND",
|
|
90
|
+
429: "RATE_LIMITED",
|
|
91
|
+
}
|
|
92
|
+
return ArkError(
|
|
93
|
+
str(envelope.get("code") or status_codes.get(response.status_code, "INTERNAL_ERROR")),
|
|
94
|
+
str(
|
|
95
|
+
envelope.get("message")
|
|
96
|
+
or (raw_error if isinstance(raw_error, str) else None)
|
|
97
|
+
or f"Request failed with status {response.status_code}"
|
|
98
|
+
),
|
|
99
|
+
status=response.status_code,
|
|
100
|
+
request_id=_optional_string(envelope.get("requestId")),
|
|
101
|
+
details=envelope.get("details") if isinstance(envelope.get("details"), dict) else None,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def network_error(error: httpx.HTTPError) -> ArkError:
|
|
106
|
+
return ArkError("NETWORK_ERROR", str(error) or "Network request failed")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def upload_error(status: int, *, part_number: int | None = None) -> ArkError:
|
|
110
|
+
if status in {401, 403}:
|
|
111
|
+
return ArkError(
|
|
112
|
+
"UPLOAD_EXPIRED",
|
|
113
|
+
"The upload authorization expired before the transfer finished. Please retry.",
|
|
114
|
+
status=status,
|
|
115
|
+
)
|
|
116
|
+
if status == 413:
|
|
117
|
+
return ArkError(
|
|
118
|
+
"FILE_TOO_LARGE",
|
|
119
|
+
"The file is larger than this upload allows.",
|
|
120
|
+
status=status,
|
|
121
|
+
)
|
|
122
|
+
label = f"part {part_number} " if part_number is not None else ""
|
|
123
|
+
return ArkError("UPLOAD_FAILED", f"Upload {label}failed with status {status}", status=status)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def invalid_argument(message: str) -> ArkError:
|
|
127
|
+
return ArkError("INVALID_ARGUMENT", message)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def invalid_response(message: str) -> ArkError:
|
|
131
|
+
"""A 2xx body the SDK could not make sense of.
|
|
132
|
+
|
|
133
|
+
Coercing an unrecognised shape to an empty list is what turned a one-line
|
|
134
|
+
parsing bug into a workflow that could never succeed: `list()` reported no
|
|
135
|
+
folders, `create()` then refused because the folder was already there, and
|
|
136
|
+
nothing anywhere raised. Failing loudly here means the next such mismatch
|
|
137
|
+
is a stack trace pointing at the response, not a silent wrong answer.
|
|
138
|
+
"""
|
|
139
|
+
return ArkError("INVALID_RESPONSE", message)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _optional_string(value: object) -> str | None:
|
|
143
|
+
return value if isinstance(value, str) else None
|
|
@@ -108,7 +108,15 @@ class ArkStream:
|
|
|
108
108
|
worse than reporting that it is unfinished. Poll ``streams.get`` until then.
|
|
109
109
|
|
|
110
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
|
|
111
|
+
Playback is signed server-side per viewer, so it carries no credential, and
|
|
112
|
+
it does not expire. It is the one playback value that is safe to store.
|
|
113
|
+
|
|
114
|
+
**Do not store ``hls_url`` or ``thumbnail_url``.** Unlike ``ArkFile.url``,
|
|
115
|
+
which is a permanent CDN path, both are signed and expire within the hour
|
|
116
|
+
(``hls_expires_at`` says when). Persist ``id`` instead and call
|
|
117
|
+
``streams.get(id)`` when you are about to play; treat them the way you
|
|
118
|
+
would a presigned URL. Writing one into a database or an email produces a
|
|
119
|
+
link that works in testing and is dead when a user opens it.
|
|
112
120
|
"""
|
|
113
121
|
|
|
114
122
|
id: str
|
|
@@ -121,6 +129,9 @@ class ArkStream:
|
|
|
121
129
|
size: int
|
|
122
130
|
thumbnail_url: str | None
|
|
123
131
|
hls_url: str | None
|
|
132
|
+
#: When ``hls_url``/``thumbnail_url`` stop working (ISO-8601), or None when
|
|
133
|
+
#: the library serves unsigned URLs that never expire.
|
|
134
|
+
hls_expires_at: str | None
|
|
124
135
|
embed_url: str | None
|
|
125
136
|
created_at: str
|
|
126
137
|
|
|
@@ -137,6 +148,7 @@ class ArkStream:
|
|
|
137
148
|
size=int(value.get("size") or 0),
|
|
138
149
|
thumbnail_url=_optional_string(value.get("thumbnailUrl")),
|
|
139
150
|
hls_url=_optional_string(value.get("hlsUrl")),
|
|
151
|
+
hls_expires_at=_optional_string(value.get("hlsExpiresAt")),
|
|
140
152
|
embed_url=_optional_string(value.get("embedUrl")),
|
|
141
153
|
created_at=str(value.get("createdAt") or ""),
|
|
142
154
|
)
|
|
@@ -21,6 +21,7 @@ from ._shared import (
|
|
|
21
21
|
iter_exact,
|
|
22
22
|
iter_file_range,
|
|
23
23
|
parse_client_session,
|
|
24
|
+
parse_folder_list,
|
|
24
25
|
query_string,
|
|
25
26
|
read_exact,
|
|
26
27
|
resolve_upload_source,
|
|
@@ -339,12 +340,7 @@ class Folders:
|
|
|
339
340
|
|
|
340
341
|
def list(self, *, parent_id: str | None = None) -> tuple[ArkFolder, ...]:
|
|
341
342
|
value = self._ark._request("GET", f"/folders{query_string({'parentId': parent_id})}")
|
|
342
|
-
|
|
343
|
-
return tuple(
|
|
344
|
-
ArkFolder.from_dict(item)
|
|
345
|
-
for item in (raw_data if isinstance(raw_data, list) else [])
|
|
346
|
-
if isinstance(item, Mapping)
|
|
347
|
-
)
|
|
343
|
+
return tuple(ArkFolder.from_dict(item) for item in parse_folder_list(value))
|
|
348
344
|
|
|
349
345
|
def create(self, name: str, *, parent_id: str | None = None) -> ArkFolder:
|
|
350
346
|
payload: dict[str, Any] = {"name": name}
|
|
@@ -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,62 @@
|
|
|
1
|
+
"""A challenge page from a CDN in front of Ark must not read as a token problem.
|
|
2
|
+
|
|
3
|
+
Regression test for a real incident: a customer's Netlify functions were
|
|
4
|
+
challenged by Cloudflare bot protection, and the SDK reported the 403 as
|
|
5
|
+
INSUFFICIENT_SCOPE. Their developer spent hours auditing token scopes for a
|
|
6
|
+
request that never reached Ark at all.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ark_py.errors import error_from_response
|
|
12
|
+
|
|
13
|
+
CHALLENGE_BODY = "<!DOCTYPE html><html><head><title>Just a moment...</title></head></html>"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _response(status, body, content_type, headers=None):
|
|
17
|
+
return httpx.Response(
|
|
18
|
+
status,
|
|
19
|
+
content=body,
|
|
20
|
+
headers={"content-type": content_type, **(headers or {})},
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_html_challenge_is_reported_as_an_edge_block():
|
|
25
|
+
error = error_from_response(
|
|
26
|
+
_response(403, CHALLENGE_BODY, "text/html; charset=UTF-8", {"cf-ray": "a376f9d70bd71709-CMH"})
|
|
27
|
+
)
|
|
28
|
+
assert error.code == "BLOCKED_BY_EDGE"
|
|
29
|
+
assert error.status == 403
|
|
30
|
+
# The ray id is the only handle on the block in the Cloudflare event log,
|
|
31
|
+
# so it has to survive into the error rather than be discarded.
|
|
32
|
+
assert error.details == {"cfRay": "a376f9d70bd71709-CMH"}
|
|
33
|
+
assert "cf-ray a376f9d70bd71709-CMH" in error.message
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_edge_block_is_retryable():
|
|
37
|
+
error = error_from_response(_response(403, CHALLENGE_BODY, "text/html"))
|
|
38
|
+
assert error.retryable is True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_a_genuine_scope_failure_is_untouched():
|
|
42
|
+
body = '{"error": {"code": "INSUFFICIENT_SCOPE", "message": "Token lacks scope"}}'
|
|
43
|
+
error = error_from_response(_response(403, body, "application/json"))
|
|
44
|
+
assert error.code == "INSUFFICIENT_SCOPE"
|
|
45
|
+
assert error.retryable is False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_non_html_403_keeps_the_status_mapping():
|
|
49
|
+
error = error_from_response(_response(403, "nope", "text/plain"))
|
|
50
|
+
assert error.code == "INSUFFICIENT_SCOPE"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_html_on_an_unrelated_status_is_not_an_edge_block():
|
|
54
|
+
# A 500 HTML page is an origin fault, not a challenge; calling it an edge
|
|
55
|
+
# block would point the developer at Cloudflare for an Ark bug.
|
|
56
|
+
error = error_from_response(_response(500, "<html>oops</html>", "text/html"))
|
|
57
|
+
assert error.code == "INTERNAL_ERROR"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_html_503_is_an_edge_block():
|
|
61
|
+
error = error_from_response(_response(503, CHALLENGE_BODY, "text/html"))
|
|
62
|
+
assert error.code == "BLOCKED_BY_EDGE"
|
|
@@ -24,6 +24,7 @@ STREAM_RESPONSE = {
|
|
|
24
24
|
"size": 42,
|
|
25
25
|
"thumbnailUrl": "https://cdn.test/thumb.jpg",
|
|
26
26
|
"hlsUrl": "https://cdn.test/playlist.m3u8",
|
|
27
|
+
"hlsExpiresAt": "2026-09-03T01:00:00.000Z",
|
|
27
28
|
"embedUrl": "https://player.test/embed",
|
|
28
29
|
"createdAt": "2026-09-03T00:00:00.000Z",
|
|
29
30
|
}
|
|
@@ -55,7 +56,16 @@ def test_resources_models_images_and_errors() -> None:
|
|
|
55
56
|
if request.url.path.endswith("/files"):
|
|
56
57
|
return json_response({"data": [file_response(4)], "nextCursor": "next"})
|
|
57
58
|
if request.url.path.endswith("/folders"):
|
|
58
|
-
|
|
59
|
+
# The envelope the API actually sends. This mock previously
|
|
60
|
+
# returned {"data": ...}, matching the SDK's mistaken expectation
|
|
61
|
+
# rather than the server, so it passed while every real call
|
|
62
|
+
# returned nothing.
|
|
63
|
+
return json_response(
|
|
64
|
+
{
|
|
65
|
+
"folders": [{"id": "folder-1", "name": "Media", "parentId": None}],
|
|
66
|
+
"pagination": {"page": 1, "limit": 50, "total": 1, "pages": 1},
|
|
67
|
+
}
|
|
68
|
+
)
|
|
59
69
|
if request.url.path.endswith("/usage"):
|
|
60
70
|
return json_response(
|
|
61
71
|
{
|
|
@@ -127,7 +137,11 @@ def test_streams_control_plane() -> None:
|
|
|
127
137
|
assert streams.create("Launch", 42, app_id="app-1").upload.endpoint.endswith("/upload")
|
|
128
138
|
assert streams.import_from_url("Remote", "https://video.test/a.mp4").id == "stream-1"
|
|
129
139
|
assert streams.list(app_id="app-1", limit=10).next_cursor == "next"
|
|
130
|
-
|
|
140
|
+
fetched = streams.get("stream-1", app_id="app-1")
|
|
141
|
+
assert fetched.hls_url is not None
|
|
142
|
+
# Playback URLs are signed and short-lived; the expiry has to survive
|
|
143
|
+
# deserialization or a caller has no way to refresh before it lapses.
|
|
144
|
+
assert fetched.hls_expires_at == "2026-09-03T01:00:00.000Z"
|
|
131
145
|
assert streams.refresh_upload_url("stream-1", app_id="app-1").endpoint.endswith("appId=app-1")
|
|
132
146
|
assert streams.delete("stream-1", app_id="app-1") is None
|
|
133
147
|
assert requests[2] == ("GET", "https://ark.test/api/v2/streams?appId=app-1&limit=10")
|
|
@@ -284,3 +298,82 @@ def test_non_seekable_stream_requires_size_and_filename() -> None:
|
|
|
284
298
|
with pytest.raises(ArkError, match="filename is required"):
|
|
285
299
|
ark.files.upload(NonSeekable(b"data"), size=4)
|
|
286
300
|
ark._client.close()
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def test_folders_list_reads_the_shape_the_api_actually_returns() -> None:
|
|
304
|
+
"""GET /v2/folders returns {"folders": [...], "pagination": {...}}.
|
|
305
|
+
|
|
306
|
+
The SDK read "data" -- the key /v2/files uses -- so this returned an empty
|
|
307
|
+
tuple against every real workspace. Nothing raised: the parser's
|
|
308
|
+
isinstance guard turned the missing key into [], so callers that resolve a
|
|
309
|
+
folder by name saw "no such folder", called create(), and got back "a
|
|
310
|
+
folder with this name already exists". That contradiction is what made the
|
|
311
|
+
bug expensive to diagnose, and it shipped because the test mock returned
|
|
312
|
+
the same wrong shape the code expected.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
316
|
+
return json_response(
|
|
317
|
+
{
|
|
318
|
+
"folders": [
|
|
319
|
+
{"id": "folder-1", "name": "Media", "parentId": None},
|
|
320
|
+
{"id": "folder-2", "name": "Docs", "parentId": None},
|
|
321
|
+
],
|
|
322
|
+
"pagination": {"page": 1, "limit": 50, "total": 2, "pages": 1},
|
|
323
|
+
}
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
client = client_for(handler)
|
|
327
|
+
ark = Ark("token", client=client)
|
|
328
|
+
folders = ark.folders.list()
|
|
329
|
+
assert [folder.name for folder in folders] == ["Media", "Docs"]
|
|
330
|
+
client.close()
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def test_folders_list_still_accepts_a_data_envelope() -> None:
|
|
334
|
+
"""A deployment that has not been updated must keep working."""
|
|
335
|
+
|
|
336
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
337
|
+
return json_response({"data": [{"id": "f1", "name": "Legacy", "parentId": None}]})
|
|
338
|
+
|
|
339
|
+
client = client_for(handler)
|
|
340
|
+
ark = Ark("token", client=client)
|
|
341
|
+
assert [folder.name for folder in ark.folders.list()] == ["Legacy"]
|
|
342
|
+
client.close()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_folders_list_rejects_a_body_it_does_not_understand() -> None:
|
|
346
|
+
"""An unrecognised body must not be silently reported as "no folders".
|
|
347
|
+
|
|
348
|
+
Returning () for a response the SDK failed to parse is what turned a
|
|
349
|
+
parsing bug into a workflow that could never succeed.
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
353
|
+
return json_response({"unexpected": []})
|
|
354
|
+
|
|
355
|
+
client = client_for(handler)
|
|
356
|
+
ark = Ark("token", client=client)
|
|
357
|
+
with pytest.raises(ArkError) as caught:
|
|
358
|
+
ark.folders.list()
|
|
359
|
+
assert caught.value.code == "INVALID_RESPONSE"
|
|
360
|
+
client.close()
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def test_folders_list_distinguishes_empty_from_unparseable() -> None:
|
|
364
|
+
"""An genuinely empty folder list is still an empty tuple, not an error."""
|
|
365
|
+
|
|
366
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
367
|
+
return json_response({"folders": [], "pagination": {"total": 0}})
|
|
368
|
+
|
|
369
|
+
client = client_for(handler)
|
|
370
|
+
ark = Ark("token", client=client)
|
|
371
|
+
assert ark.folders.list() == ()
|
|
372
|
+
client.close()
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def test_ark_error_exposes_message() -> None:
|
|
376
|
+
"""The README documents `message`; only `str(error)` actually worked."""
|
|
377
|
+
error = ArkError("NOT_FOUND", "missing", status=404)
|
|
378
|
+
assert error.message == "missing"
|
|
379
|
+
assert str(error) == "missing"
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from typing import Any
|
|
4
|
-
|
|
5
|
-
import httpx
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class ArkError(Exception):
|
|
9
|
-
"""A normalized Ark REST or upload error."""
|
|
10
|
-
|
|
11
|
-
def __init__(
|
|
12
|
-
self,
|
|
13
|
-
code: str,
|
|
14
|
-
message: str,
|
|
15
|
-
*,
|
|
16
|
-
status: int | None = None,
|
|
17
|
-
request_id: str | None = None,
|
|
18
|
-
details: dict[str, Any] | None = None,
|
|
19
|
-
) -> None:
|
|
20
|
-
super().__init__(message)
|
|
21
|
-
self.code = code
|
|
22
|
-
self.status = status
|
|
23
|
-
self.request_id = request_id
|
|
24
|
-
self.details = details
|
|
25
|
-
|
|
26
|
-
@property
|
|
27
|
-
def retryable(self) -> bool:
|
|
28
|
-
return self.code in {"NETWORK_ERROR", "RATE_LIMITED"} or (
|
|
29
|
-
self.status is not None and self.status >= 500
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def error_from_response(response: httpx.Response) -> ArkError:
|
|
34
|
-
try:
|
|
35
|
-
body = response.json()
|
|
36
|
-
except ValueError:
|
|
37
|
-
body = {}
|
|
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
|
-
}
|
|
48
|
-
return ArkError(
|
|
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
|
-
),
|
|
55
|
-
status=response.status_code,
|
|
56
|
-
request_id=_optional_string(envelope.get("requestId")),
|
|
57
|
-
details=envelope.get("details") if isinstance(envelope.get("details"), dict) else None,
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def network_error(error: httpx.HTTPError) -> ArkError:
|
|
62
|
-
return ArkError("NETWORK_ERROR", str(error) or "Network request failed")
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def upload_error(status: int, *, part_number: int | None = None) -> ArkError:
|
|
66
|
-
if status in {401, 403}:
|
|
67
|
-
return ArkError(
|
|
68
|
-
"UPLOAD_EXPIRED",
|
|
69
|
-
"The upload authorization expired before the transfer finished. Please retry.",
|
|
70
|
-
status=status,
|
|
71
|
-
)
|
|
72
|
-
if status == 413:
|
|
73
|
-
return ArkError(
|
|
74
|
-
"FILE_TOO_LARGE",
|
|
75
|
-
"The file is larger than this upload allows.",
|
|
76
|
-
status=status,
|
|
77
|
-
)
|
|
78
|
-
label = f"part {part_number} " if part_number is not None else ""
|
|
79
|
-
return ArkError("UPLOAD_FAILED", f"Upload {label}failed with status {status}", status=status)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def invalid_argument(message: str) -> ArkError:
|
|
83
|
-
return ArkError("INVALID_ARGUMENT", message)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def _optional_string(value: object) -> str | None:
|
|
87
|
-
return value if isinstance(value, str) else None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|