nerdstack-ark 1.0.5__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.
Files changed (23) hide show
  1. {nerdstack_ark-1.0.5 → nerdstack_ark-1.0.6}/CHANGELOG.md +15 -0
  2. {nerdstack_ark-1.0.5 → nerdstack_ark-1.0.6}/PKG-INFO +1 -1
  3. {nerdstack_ark-1.0.5 → nerdstack_ark-1.0.6}/pyproject.toml +1 -1
  4. {nerdstack_ark-1.0.5 → nerdstack_ark-1.0.6}/src/ark_py/errors.py +40 -1
  5. {nerdstack_ark-1.0.5 → nerdstack_ark-1.0.6}/src/ark_py/models.py +13 -1
  6. nerdstack_ark-1.0.6/tests/test_edge_block.py +62 -0
  7. {nerdstack_ark-1.0.5 → nerdstack_ark-1.0.6}/tests/test_sync.py +6 -1
  8. {nerdstack_ark-1.0.5 → nerdstack_ark-1.0.6}/.gitignore +0 -0
  9. {nerdstack_ark-1.0.5 → nerdstack_ark-1.0.6}/LICENSE +0 -0
  10. {nerdstack_ark-1.0.5 → nerdstack_ark-1.0.6}/README.md +0 -0
  11. {nerdstack_ark-1.0.5 → nerdstack_ark-1.0.6}/examples/django/apps.py +0 -0
  12. {nerdstack_ark-1.0.5 → nerdstack_ark-1.0.6}/examples/django/views.py +0 -0
  13. {nerdstack_ark-1.0.5 → nerdstack_ark-1.0.6}/examples/fastapi/app.py +0 -0
  14. {nerdstack_ark-1.0.5 → nerdstack_ark-1.0.6}/examples/flask/app.py +0 -0
  15. {nerdstack_ark-1.0.5 → nerdstack_ark-1.0.6}/src/ark_py/__init__.py +0 -0
  16. {nerdstack_ark-1.0.5 → nerdstack_ark-1.0.6}/src/ark_py/_shared.py +0 -0
  17. {nerdstack_ark-1.0.5 → nerdstack_ark-1.0.6}/src/ark_py/async_client.py +0 -0
  18. {nerdstack_ark-1.0.5 → nerdstack_ark-1.0.6}/src/ark_py/py.typed +0 -0
  19. {nerdstack_ark-1.0.5 → nerdstack_ark-1.0.6}/src/ark_py/s3.py +0 -0
  20. {nerdstack_ark-1.0.5 → nerdstack_ark-1.0.6}/src/ark_py/sync.py +0 -0
  21. {nerdstack_ark-1.0.5 → nerdstack_ark-1.0.6}/tests/conftest.py +0 -0
  22. {nerdstack_ark-1.0.5 → nerdstack_ark-1.0.6}/tests/test_async.py +0 -0
  23. {nerdstack_ark-1.0.5 → nerdstack_ark-1.0.6}/tests/test_stream_upload.py +0 -0
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## 1.0.5
4
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
+
5
20
  ### Fixed
6
21
 
7
22
  - `folders.list()` and `AsyncFolders.list()` returned an empty tuple against
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: nerdstack-ark
3
- Version: 1.0.5
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nerdstack-ark"
7
- version = "1.0.5"
7
+ version = "1.0.6"
8
8
  description = "Official Python SDK for Ark storage, with sync, async, and S3-compatible access."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -30,16 +30,55 @@ class ArkError(Exception):
30
30
 
31
31
  @property
32
32
  def retryable(self) -> bool:
33
- return self.code in {"NETWORK_ERROR", "RATE_LIMITED"} or (
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 (
34
36
  self.status is not None and self.status >= 500
35
37
  )
36
38
 
37
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
+
38
58
  def error_from_response(response: httpx.Response) -> ArkError:
59
+ parsed = True
39
60
  try:
40
61
  body = response.json()
41
62
  except ValueError:
42
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
+
43
82
  raw_error = body.get("error") if isinstance(body, dict) else None
44
83
  envelope = raw_error if isinstance(raw_error, dict) else {}
45
84
  status_codes = {
@@ -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
  )
@@ -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
  }
@@ -136,7 +137,11 @@ def test_streams_control_plane() -> None:
136
137
  assert streams.create("Launch", 42, app_id="app-1").upload.endpoint.endswith("/upload")
137
138
  assert streams.import_from_url("Remote", "https://video.test/a.mp4").id == "stream-1"
138
139
  assert streams.list(app_id="app-1", limit=10).next_cursor == "next"
139
- assert streams.get("stream-1", app_id="app-1").hls_url is not None
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"
140
145
  assert streams.refresh_upload_url("stream-1", app_id="app-1").endpoint.endswith("appId=app-1")
141
146
  assert streams.delete("stream-1", app_id="app-1") is None
142
147
  assert requests[2] == ("GET", "https://ark.test/api/v2/streams?appId=app-1&limit=10")
File without changes
File without changes
File without changes