diffusr 0.1.0__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.
@@ -0,0 +1,34 @@
1
+ .DS_Store
2
+ node_modules/
3
+ .next/
4
+ .turbo/
5
+ dist/
6
+ .env
7
+ .env.*
8
+ !.env.example
9
+ *.tsbuildinfo
10
+
11
+ # Trigger.dev CLI local state (builds, locks, watchdog)
12
+ .trigger/
13
+ pw-*.png
14
+ .vercel
15
+
16
+ # trigger.dev CLI (>=4.5.14) regenerates agent-skill scaffolding on every dev start
17
+ packages/jobs/.agents/
18
+ packages/jobs/.claude/
19
+ packages/jobs/.cursor/
20
+ packages/jobs/AGENTS.md
21
+ packages/jobs/CLAUDE.md
22
+
23
+ # Herdr foreman briefs handed to workspace agents — never committed
24
+ .brief-*.md
25
+
26
+ # Director scenario runner output (transcripts per run)
27
+ scripts/director-scenarios/runs/
28
+
29
+ # Format-refs PNGs live on R2 (scripts/upload-format-refs.ts → refs-manifest.json)
30
+ docs/format-refs/format-templates-export/formats/*/refs/*.png
31
+ docs/format-refs/format-templates-export/formats/*/refs/*.jpg
32
+
33
+ # Machine notes for Jose's dev boxes (symlinked into worktrees by post-checkout)
34
+ CLAUDE.local.md*
diffusr-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 diffusr
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
diffusr-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.5
2
+ Name: diffusr
3
+ Version: 0.1.0
4
+ Summary: The diffusr API client: make videos, images and music from code. Free price checks, idempotent starts, wait_for, signed callbacks.
5
+ Project-URL: Homepage, https://diffusr.ai
6
+ Project-URL: Documentation, https://diffusr.ai/docs
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: ai,api,diffusr,image,video
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+
16
+ # diffusr
17
+
18
+ The diffusr API client for Python 3.9+. Make videos, images and music from
19
+ code. Standard library only. Bodies and answers are the API's own snake_case
20
+ JSON (plain dicts).
21
+
22
+ ```sh
23
+ pip install diffusr
24
+ ```
25
+
26
+ ## Quickstart
27
+
28
+ Use a **test key** (`dfsr_test_…`) while you build: prices are quoted exactly,
29
+ nothing is charged, no model runs, and the result is a placeholder file a few
30
+ seconds later. Swap in a live key (`dfsr_live_…`) when you are ready to spend.
31
+ Mint keys on the Developers page in the app.
32
+
33
+ ```python
34
+ from diffusr import Diffusr
35
+
36
+ diffusr = Diffusr() # reads DIFFUSR_KEY
37
+
38
+ # 1. Ask the price. Free: nothing is made, nothing is spent.
39
+ quote = diffusr.make_video(
40
+ prompt="A can of sparkling water on a wet street, slow push-in, rain",
41
+ duration_sec=5,
42
+ dry_run=True,
43
+ )
44
+ print(quote["price"]) # {'credits': 63, 'usd': '0.6300', ...}
45
+
46
+ # 2. Make it. An Idempotency-Key is added for you and reused on retries,
47
+ # so a dropped connection never pays twice.
48
+ job = diffusr.make_video(
49
+ prompt="A can of sparkling water on a wet street, slow push-in, rain",
50
+ duration_sec=5,
51
+ )
52
+
53
+ # 3. Wait for it (polls with backoff; raises WAIT_TIMEOUT if it takes too long).
54
+ result = diffusr.wait_for(job["id"], timeout=600)
55
+ if result["status"] == "SUCCEEDED":
56
+ print(result["output"]["url"])
57
+ else:
58
+ print(result["status"], result.get("error"))
59
+ print(result["charge"]) # quoted_credits, charged_credits, final
60
+ ```
61
+
62
+ ### Callbacks instead of polling
63
+
64
+ Pass `callback_url` (https, public) and we POST one signed event when the job
65
+ ends. Verify it with your signing secret (`diffusr.get_callback_secret()["secret"]`):
66
+
67
+ ```python
68
+ from diffusr import Diffusr
69
+
70
+ # Flask: use the RAW body, not the parsed one.
71
+ @app.post("/diffusr")
72
+ def hook():
73
+ raw = request.get_data(as_text=True)
74
+ if not Diffusr.verify_callback(raw, request.headers, os.environ["DIFFUSR_WHSEC"]):
75
+ return "", 400
76
+ event = json.loads(raw) # {id, type: "result.finished", data: <result>}
77
+ return "", 200 # any 2xx; drop repeats of event["id"]
78
+ ```
79
+
80
+ `verify_callback` implements [Standard Webhooks](https://www.standardwebhooks.com):
81
+ constant-time compare, two signatures after a secret rotation, a 5-minute
82
+ timestamp window, `whsec_` prefix handled.
83
+
84
+ ## Methods
85
+
86
+ | Method | REST |
87
+ |---|---|
88
+ | `make_video`, `make_image`, `edit_image`, `recast`, `make_music`, `creator_video`, `run_template` `(**body)` | `POST /{tool}` |
89
+ | `get_result(id)`, `list_results(limit=)`, `cancel(id)` (stops queued work; a step already generating may finish and be charged: check `result["charge"]`) | `GET /results/{id}`, `GET /results`, `POST /results/{id}/cancel` |
90
+ | `wait_for(id, timeout=, interval=, on_poll=)` | polls `GET /results/{id}` |
91
+ | `upload_file(path_or_bytes, content_type=)`, `upload_from_url(url)` | `POST /uploads` + signed `PUT` |
92
+ | `list_models(kind=)`, `list_templates()` | `GET /models`, `GET /templates` |
93
+ | `list_callbacks(limit=, cursor=)`, `retry_callback(event_id)`, `get_callback_secret()`, `rotate_callback_secret()` | `/callbacks…` |
94
+ | `Diffusr.verify_callback(raw_body, headers, secret)` | |
95
+
96
+ `run_template` takes one flat body: `template="<id from list_templates()>"` plus the fields that
97
+ template takes (`mode`, `prompt`, `pictures`, `image_urls`, `product_image_url`, `script`, `place`,
98
+ `people`, `setup`, `resolution`):
99
+
100
+ ```python
101
+ diffusr.run_template(template="tpl-ugc-talking-head", script="Hi!", people=[{"name": "Mia", "image_url": "https://…"}], dry_run=True)
102
+ ```
103
+
104
+ `dry_run=True` on any make call answers with the price only. Pass
105
+ `idempotency_key="…"` to use your own key.
106
+
107
+ ## Retries and errors
108
+
109
+ - Every request times out after 60 s (`Diffusr(timeout=...)`) with a `TIMEOUT` error.
110
+ - `GET`s (and so `wait_for`) retry on 429, 500, 502, 503, 504, network errors
111
+ and timeouts with backoff, honouring `Retry-After` (waits over 30 s are not
112
+ slept: the error is raised).
113
+ - Make calls retry on 429, 503, network errors and timeouts only, **with the
114
+ same Idempotency-Key**.
115
+ - Every failure is a `DiffusrError`:
116
+
117
+ ```python
118
+ from diffusr import DiffusrError
119
+
120
+ try:
121
+ diffusr.make_video(prompt="…")
122
+ except DiffusrError as e:
123
+ print(e.status, e.code, e.message, e.request_id, e.retry_after)
124
+ # e.body has the extras: issues, field, budget…
125
+ ```
126
+
127
+ Codes are the API's (`RATE_LIMITED`, `BUDGET_EXCEEDED`, `INSUFFICIENT_CREDITS`…).
128
+ The SDK adds `NETWORK_ERROR`, `TIMEOUT`, `WAIT_TIMEOUT`, `UPLOAD_FAILED`,
129
+ `CONTENT_TYPE_REQUIRED` and `MISSING_API_KEY`. Handle codes you don't know.
130
+
131
+ ## Docs
132
+
133
+ Full API reference, prices and agent guides: https://diffusr.ai/docs
134
+
135
+ MIT licensed.
@@ -0,0 +1,120 @@
1
+ # diffusr
2
+
3
+ The diffusr API client for Python 3.9+. Make videos, images and music from
4
+ code. Standard library only. Bodies and answers are the API's own snake_case
5
+ JSON (plain dicts).
6
+
7
+ ```sh
8
+ pip install diffusr
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ Use a **test key** (`dfsr_test_…`) while you build: prices are quoted exactly,
14
+ nothing is charged, no model runs, and the result is a placeholder file a few
15
+ seconds later. Swap in a live key (`dfsr_live_…`) when you are ready to spend.
16
+ Mint keys on the Developers page in the app.
17
+
18
+ ```python
19
+ from diffusr import Diffusr
20
+
21
+ diffusr = Diffusr() # reads DIFFUSR_KEY
22
+
23
+ # 1. Ask the price. Free: nothing is made, nothing is spent.
24
+ quote = diffusr.make_video(
25
+ prompt="A can of sparkling water on a wet street, slow push-in, rain",
26
+ duration_sec=5,
27
+ dry_run=True,
28
+ )
29
+ print(quote["price"]) # {'credits': 63, 'usd': '0.6300', ...}
30
+
31
+ # 2. Make it. An Idempotency-Key is added for you and reused on retries,
32
+ # so a dropped connection never pays twice.
33
+ job = diffusr.make_video(
34
+ prompt="A can of sparkling water on a wet street, slow push-in, rain",
35
+ duration_sec=5,
36
+ )
37
+
38
+ # 3. Wait for it (polls with backoff; raises WAIT_TIMEOUT if it takes too long).
39
+ result = diffusr.wait_for(job["id"], timeout=600)
40
+ if result["status"] == "SUCCEEDED":
41
+ print(result["output"]["url"])
42
+ else:
43
+ print(result["status"], result.get("error"))
44
+ print(result["charge"]) # quoted_credits, charged_credits, final
45
+ ```
46
+
47
+ ### Callbacks instead of polling
48
+
49
+ Pass `callback_url` (https, public) and we POST one signed event when the job
50
+ ends. Verify it with your signing secret (`diffusr.get_callback_secret()["secret"]`):
51
+
52
+ ```python
53
+ from diffusr import Diffusr
54
+
55
+ # Flask: use the RAW body, not the parsed one.
56
+ @app.post("/diffusr")
57
+ def hook():
58
+ raw = request.get_data(as_text=True)
59
+ if not Diffusr.verify_callback(raw, request.headers, os.environ["DIFFUSR_WHSEC"]):
60
+ return "", 400
61
+ event = json.loads(raw) # {id, type: "result.finished", data: <result>}
62
+ return "", 200 # any 2xx; drop repeats of event["id"]
63
+ ```
64
+
65
+ `verify_callback` implements [Standard Webhooks](https://www.standardwebhooks.com):
66
+ constant-time compare, two signatures after a secret rotation, a 5-minute
67
+ timestamp window, `whsec_` prefix handled.
68
+
69
+ ## Methods
70
+
71
+ | Method | REST |
72
+ |---|---|
73
+ | `make_video`, `make_image`, `edit_image`, `recast`, `make_music`, `creator_video`, `run_template` `(**body)` | `POST /{tool}` |
74
+ | `get_result(id)`, `list_results(limit=)`, `cancel(id)` (stops queued work; a step already generating may finish and be charged: check `result["charge"]`) | `GET /results/{id}`, `GET /results`, `POST /results/{id}/cancel` |
75
+ | `wait_for(id, timeout=, interval=, on_poll=)` | polls `GET /results/{id}` |
76
+ | `upload_file(path_or_bytes, content_type=)`, `upload_from_url(url)` | `POST /uploads` + signed `PUT` |
77
+ | `list_models(kind=)`, `list_templates()` | `GET /models`, `GET /templates` |
78
+ | `list_callbacks(limit=, cursor=)`, `retry_callback(event_id)`, `get_callback_secret()`, `rotate_callback_secret()` | `/callbacks…` |
79
+ | `Diffusr.verify_callback(raw_body, headers, secret)` | |
80
+
81
+ `run_template` takes one flat body: `template="<id from list_templates()>"` plus the fields that
82
+ template takes (`mode`, `prompt`, `pictures`, `image_urls`, `product_image_url`, `script`, `place`,
83
+ `people`, `setup`, `resolution`):
84
+
85
+ ```python
86
+ diffusr.run_template(template="tpl-ugc-talking-head", script="Hi!", people=[{"name": "Mia", "image_url": "https://…"}], dry_run=True)
87
+ ```
88
+
89
+ `dry_run=True` on any make call answers with the price only. Pass
90
+ `idempotency_key="…"` to use your own key.
91
+
92
+ ## Retries and errors
93
+
94
+ - Every request times out after 60 s (`Diffusr(timeout=...)`) with a `TIMEOUT` error.
95
+ - `GET`s (and so `wait_for`) retry on 429, 500, 502, 503, 504, network errors
96
+ and timeouts with backoff, honouring `Retry-After` (waits over 30 s are not
97
+ slept: the error is raised).
98
+ - Make calls retry on 429, 503, network errors and timeouts only, **with the
99
+ same Idempotency-Key**.
100
+ - Every failure is a `DiffusrError`:
101
+
102
+ ```python
103
+ from diffusr import DiffusrError
104
+
105
+ try:
106
+ diffusr.make_video(prompt="…")
107
+ except DiffusrError as e:
108
+ print(e.status, e.code, e.message, e.request_id, e.retry_after)
109
+ # e.body has the extras: issues, field, budget…
110
+ ```
111
+
112
+ Codes are the API's (`RATE_LIMITED`, `BUDGET_EXCEEDED`, `INSUFFICIENT_CREDITS`…).
113
+ The SDK adds `NETWORK_ERROR`, `TIMEOUT`, `WAIT_TIMEOUT`, `UPLOAD_FAILED`,
114
+ `CONTENT_TYPE_REQUIRED` and `MISSING_API_KEY`. Handle codes you don't know.
115
+
116
+ ## Docs
117
+
118
+ Full API reference, prices and agent guides: https://diffusr.ai/docs
119
+
120
+ MIT licensed.
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "diffusr"
7
+ version = "0.1.0"
8
+ description = "The diffusr API client: make videos, images and music from code. Free price checks, idempotent starts, wait_for, signed callbacks."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ dependencies = []
13
+ keywords = ["diffusr", "video", "image", "ai", "api"]
14
+ classifiers = [
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Typing :: Typed",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://diffusr.ai"
22
+ Documentation = "https://diffusr.ai/docs"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/diffusr"]
@@ -0,0 +1,20 @@
1
+ """The diffusr API client. Stdlib only.
2
+
3
+ from diffusr import Diffusr
4
+ d = Diffusr() # reads DIFFUSR_KEY
5
+ quote = d.make_video(prompt="...", dry_run=True) # free
6
+ """
7
+
8
+ from ._client import DEFAULT_BASE_URL, TERMINAL_STATUSES, Diffusr
9
+ from ._errors import DiffusrError
10
+ from ._verify import SIGNATURE_TOLERANCE_SEC, verify_callback
11
+
12
+ __all__ = [
13
+ "Diffusr",
14
+ "DiffusrError",
15
+ "verify_callback",
16
+ "DEFAULT_BASE_URL",
17
+ "TERMINAL_STATUSES",
18
+ "SIGNATURE_TOLERANCE_SEC",
19
+ ]
20
+ __version__ = "0.1.0"
@@ -0,0 +1,287 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import mimetypes
5
+ import os
6
+ import socket
7
+ import time
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ import uuid
12
+ from typing import Any, Callable, Dict, Mapping, Optional, Union
13
+
14
+ from ._errors import DiffusrError
15
+ from ._verify import verify_callback as _verify_callback
16
+
17
+ DEFAULT_BASE_URL = "https://app.diffusr.ai/api/v2"
18
+ SDK_VERSION = "0.1.0"
19
+ TERMINAL_STATUSES = ("SUCCEEDED", "FAILED", "CANCELED")
20
+ # Never sleep longer than this on one automatic retry; raise instead.
21
+ MAX_AUTO_RETRY_WAIT_SEC = 30
22
+
23
+ _CONTENT_TYPES = {
24
+ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp",
25
+ ".gif": "image/gif", ".mp4": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm",
26
+ ".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4",
27
+ }
28
+
29
+
30
+ class Diffusr:
31
+ """Client for the diffusr REST API (v2). Bodies and answers are the API's own snake_case JSON.
32
+
33
+ api_key dfsr_live_... or dfsr_test_... (free). Default: the DIFFUSR_KEY environment variable.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ api_key: Optional[str] = None,
39
+ base_url: Optional[str] = None,
40
+ *,
41
+ max_retries: int = 3,
42
+ retry_base: float = 0.5,
43
+ timeout: float = 60.0,
44
+ ) -> None:
45
+ key = api_key or os.environ.get("DIFFUSR_KEY")
46
+ if not key:
47
+ raise DiffusrError(0, "MISSING_API_KEY", "Pass an API key or set DIFFUSR_KEY.")
48
+ self._key = key
49
+ self._base = (base_url or DEFAULT_BASE_URL).rstrip("/")
50
+ self._max_retries = max_retries
51
+ self._retry_base = retry_base
52
+ self._timeout = timeout
53
+
54
+ # ——— make calls (paid; dry_run=True is free) ———
55
+
56
+ def make_video(self, **body: Any) -> Dict[str, Any]:
57
+ return self._make("make_video", body)
58
+
59
+ def make_image(self, **body: Any) -> Dict[str, Any]:
60
+ return self._make("make_image", body)
61
+
62
+ def edit_image(self, **body: Any) -> Dict[str, Any]:
63
+ return self._make("edit_image", body)
64
+
65
+ def recast(self, **body: Any) -> Dict[str, Any]:
66
+ return self._make("recast", body)
67
+
68
+ def make_music(self, **body: Any) -> Dict[str, Any]:
69
+ return self._make("make_music", body)
70
+
71
+ def creator_video(self, **body: Any) -> Dict[str, Any]:
72
+ return self._make("creator_video", body)
73
+
74
+ def run_template(self, **body: Any) -> Dict[str, Any]:
75
+ """Run one of diffusr's templates. One flat body: template="<id from list_templates()>",
76
+ plus the fields that template takes (mode, prompt, pictures, image_urls, product_image_url,
77
+ script, place, people, setup, resolution, dry_run, callback_url)."""
78
+ return self._make("run_template", body)
79
+
80
+ def _make(self, tool: str, body: Dict[str, Any]) -> Dict[str, Any]:
81
+ # One Idempotency-Key per call, reused by every automatic retry.
82
+ key = body.pop("idempotency_key", None) or str(uuid.uuid4())
83
+ return self._request("POST", f"/{tool}", body=body, headers={"Idempotency-Key": key})
84
+
85
+ # ——— results ———
86
+
87
+ def get_result(self, id: str) -> Dict[str, Any]:
88
+ return self._request("GET", f"/results/{urllib.parse.quote(id, safe='')}")
89
+
90
+ def list_results(self, limit: Optional[int] = None) -> Dict[str, Any]:
91
+ return self._request("GET", "/results" + _query(limit=limit))
92
+
93
+ def cancel(self, id: str) -> Dict[str, Any]:
94
+ """Stop queued work before it generates (that part is never charged).
95
+
96
+ A step that is already generating may still finish and be charged: check
97
+ result["charge"] on the answer. A finished job comes back unchanged.
98
+ """
99
+ return self._request("POST", f"/results/{urllib.parse.quote(id, safe='')}/cancel", body={})
100
+
101
+ def wait_for(
102
+ self,
103
+ id: str,
104
+ timeout: float = 900.0,
105
+ interval: float = 3.0,
106
+ on_poll: Optional[Callable[[Dict[str, Any]], None]] = None,
107
+ ) -> Dict[str, Any]:
108
+ """Poll until the job is over and return the result (seconds, not ms).
109
+
110
+ A job that ends FAILED or CANCELED is returned too: check result["status"].
111
+ Raises DiffusrError(code="WAIT_TIMEOUT") if it is still running at the
112
+ deadline (the job itself keeps going).
113
+ """
114
+ deadline = time.monotonic() + timeout
115
+ pause = interval
116
+ while True:
117
+ result = self.get_result(id)
118
+ if on_poll:
119
+ on_poll(result)
120
+ if result.get("status") in TERMINAL_STATUSES:
121
+ return result
122
+ left = deadline - time.monotonic()
123
+ if left <= 0:
124
+ break
125
+ time.sleep(min(pause, left))
126
+ pause = min(pause * 1.5, max(15.0, interval))
127
+ if time.monotonic() >= deadline:
128
+ break
129
+ raise DiffusrError(
130
+ 0,
131
+ "WAIT_TIMEOUT",
132
+ f"Result {id} was still not finished after {int(timeout)} s. It keeps running: call wait_for or get_result again.",
133
+ )
134
+
135
+ # ——— files ———
136
+
137
+ def upload_file(
138
+ self, file: Union[str, "os.PathLike[str]", bytes, bytearray], content_type: Optional[str] = None
139
+ ) -> Dict[str, Any]:
140
+ """Upload a local file (a path, or bytes) and get a file_url to pass to a tool.
141
+
142
+ Two steps: ask for a signed URL, PUT the exact bytes to it.
143
+ """
144
+ if isinstance(file, (bytes, bytearray)):
145
+ data = bytes(file)
146
+ else:
147
+ path = os.fspath(file)
148
+ with open(path, "rb") as f:
149
+ data = f.read()
150
+ content_type = content_type or _CONTENT_TYPES.get(os.path.splitext(path)[1].lower()) or mimetypes.guess_type(path)[0]
151
+ if not content_type:
152
+ raise DiffusrError(0, "CONTENT_TYPE_REQUIRED", 'Pass content_type (e.g. "video/mp4"): it could not be worked out.')
153
+ ticket = self._request("POST", "/uploads", body={"content_type": content_type, "size_bytes": len(data)})
154
+ req = urllib.request.Request(ticket["upload_url"], data=data, method=ticket.get("method", "PUT"))
155
+ for k, v in ticket.get("headers", {}).items():
156
+ req.add_header(k, v)
157
+ try:
158
+ with urllib.request.urlopen(req, timeout=self._timeout):
159
+ pass
160
+ except urllib.error.HTTPError as e:
161
+ raise DiffusrError(e.code, "UPLOAD_FAILED", f"The upload PUT failed with HTTP {e.code}.") from None
162
+ except urllib.error.URLError as e:
163
+ raise DiffusrError(0, "NETWORK_ERROR", f"The upload PUT failed: {e.reason}") from None
164
+ return {"file_url": ticket["file_url"], "kind": ticket.get("kind")}
165
+
166
+ def upload_from_url(self, source_url: str) -> Dict[str, Any]:
167
+ """Have us copy a public link in (images and short clips; use upload_file for big videos)."""
168
+ return self._request("POST", "/uploads", body={"source_url": source_url})
169
+
170
+ # ——— catalog ———
171
+
172
+ def list_models(self, kind: Optional[str] = None) -> Dict[str, Any]:
173
+ return self._request("GET", "/models" + _query(kind=kind))
174
+
175
+ def list_templates(self) -> Dict[str, Any]:
176
+ return self._request("GET", "/templates")
177
+
178
+ # ——— callbacks ———
179
+
180
+ def list_callbacks(self, limit: Optional[int] = None, cursor: Optional[str] = None) -> Dict[str, Any]:
181
+ return self._request("GET", "/callbacks" + _query(limit=limit, cursor=cursor))
182
+
183
+ def retry_callback(self, event_id: str) -> Dict[str, Any]:
184
+ """One more try for a FAILED event (up to 3 by hand)."""
185
+ return self._request("POST", f"/callbacks/{urllib.parse.quote(event_id, safe='')}/retry", body={})
186
+
187
+ def get_callback_secret(self) -> Dict[str, Any]:
188
+ """Your signing secret (whsec_...) for Diffusr.verify_callback."""
189
+ return self._request("GET", "/callbacks/secret")
190
+
191
+ def rotate_callback_secret(self) -> Dict[str, Any]:
192
+ return self._request("POST", "/callbacks/secret/rotate", body={})
193
+
194
+ @staticmethod
195
+ def verify_callback(
196
+ raw_body: Union[str, bytes],
197
+ headers: Mapping[str, str],
198
+ secret: str,
199
+ tolerance_sec: int = 300,
200
+ now: Optional[float] = None,
201
+ ) -> bool:
202
+ """Check a callback's signature (Standard Webhooks). Rejects a timestamp more than 5 minutes off."""
203
+ return _verify_callback(raw_body, headers, secret, tolerance_sec, now)
204
+
205
+ # ——— plumbing ———
206
+
207
+ def _backoff(self, attempt: int) -> float:
208
+ return self._retry_base * (2**attempt)
209
+
210
+ def _request(
211
+ self, method: str, path: str, body: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None
212
+ ) -> Dict[str, Any]:
213
+ hdrs = {
214
+ "Authorization": f"Bearer {self._key}",
215
+ "Accept": "application/json",
216
+ "User-Agent": f"diffusr-python/{SDK_VERSION}",
217
+ }
218
+ data = None
219
+ if body is not None:
220
+ hdrs["Content-Type"] = "application/json"
221
+ data = json.dumps(body).encode("utf-8")
222
+ hdrs.update(headers or {})
223
+
224
+ attempt = 0
225
+ while True:
226
+ req = urllib.request.Request(self._base + path, data=data, method=method, headers=hdrs)
227
+ try:
228
+ with urllib.request.urlopen(req, timeout=self._timeout) as res:
229
+ text = res.read().decode("utf-8")
230
+ return json.loads(text) if text else {}
231
+ except urllib.error.HTTPError as e:
232
+ err = _to_error(e)
233
+ wait = err.retry_after if err.retry_after is not None else self._backoff(attempt)
234
+ retryable = e.code in (429, 503) or (method == "GET" and e.code in (500, 502, 504))
235
+ if retryable and attempt < self._max_retries and wait <= MAX_AUTO_RETRY_WAIT_SEC:
236
+ time.sleep(wait)
237
+ attempt += 1
238
+ continue
239
+ raise err from None
240
+ except (urllib.error.URLError, OSError) as e:
241
+ # OSError covers socket.timeout (a separate class before 3.10), TimeoutError, ConnectionError.
242
+ if attempt < self._max_retries:
243
+ time.sleep(self._backoff(attempt))
244
+ attempt += 1
245
+ continue
246
+ reason = getattr(e, "reason", e)
247
+ if _is_timeout(e) or _is_timeout(reason):
248
+ raise DiffusrError(0, "TIMEOUT", f"diffusr did not answer within {self._timeout:g} s.") from None
249
+ raise DiffusrError(0, "NETWORK_ERROR", f"Could not reach diffusr: {reason}") from None
250
+
251
+
252
+ def _is_timeout(e: Any) -> bool:
253
+ return isinstance(e, (socket.timeout, TimeoutError))
254
+
255
+
256
+ def _query(**params: Any) -> str:
257
+ q = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
258
+ return f"?{q}" if q else ""
259
+
260
+
261
+ def _to_error(e: urllib.error.HTTPError) -> DiffusrError:
262
+ text = ""
263
+ try:
264
+ text = e.read().decode("utf-8", "replace")
265
+ except Exception:
266
+ pass
267
+ body: Optional[Dict[str, Any]] = None
268
+ try:
269
+ parsed = json.loads(text)
270
+ if isinstance(parsed, dict):
271
+ body = parsed
272
+ except ValueError:
273
+ pass
274
+ retry_after: Optional[float] = None
275
+ header = e.headers.get("Retry-After") if e.headers else None
276
+ if header is not None:
277
+ try:
278
+ retry_after = float(header)
279
+ except ValueError:
280
+ pass
281
+ if retry_after is None and body and isinstance(body.get("retry_after_seconds"), (int, float)):
282
+ retry_after = float(body["retry_after_seconds"])
283
+ message = body["error"] if body and isinstance(body.get("error"), str) else (text[:300] or f"HTTP {e.code}")
284
+ code = body["code"] if body and isinstance(body.get("code"), str) else f"HTTP_{e.code}"
285
+ request_id = body.get("request_id") if body and isinstance(body.get("request_id"), str) else None
286
+ request_id = request_id or (e.headers.get("X-Request-Id") if e.headers else None)
287
+ return DiffusrError(e.code, code, message, request_id, retry_after, body)
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+
6
+ class DiffusrError(Exception):
7
+ """Every non-2xx answer (and a few client-side failures) raises this.
8
+
9
+ status HTTP status; 0 when no answer came (network error, wait timeout)
10
+ code the API's machine code, e.g. "RATE_LIMITED"
11
+ message the API's plain-words error
12
+ request_id quote it to support
13
+ retry_after seconds to wait before trying again, when the API said so
14
+ body the whole JSON error body (field, issues, budget...)
15
+ """
16
+
17
+ def __init__(
18
+ self,
19
+ status: int,
20
+ code: str,
21
+ message: str,
22
+ request_id: Optional[str] = None,
23
+ retry_after: Optional[float] = None,
24
+ body: Optional[Dict[str, Any]] = None,
25
+ ) -> None:
26
+ super().__init__(message)
27
+ self.status = status
28
+ self.code = code
29
+ self.message = message
30
+ self.request_id = request_id
31
+ self.retry_after = retry_after
32
+ self.body = body
33
+
34
+ def __str__(self) -> str:
35
+ rid = f" (request_id {self.request_id})" if self.request_id else ""
36
+ return f"[{self.code}] {self.message}{rid}"
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import hashlib
6
+ import hmac
7
+ import time
8
+ from typing import Mapping, Optional, Union
9
+
10
+ SIGNATURE_TOLERANCE_SEC = 300
11
+
12
+
13
+ def _header(headers: Mapping[str, str], name: str) -> Optional[str]:
14
+ for k, v in headers.items():
15
+ if k.lower() == name:
16
+ return v if isinstance(v, str) else " ".join(v)
17
+ return None
18
+
19
+
20
+ def verify_callback(
21
+ raw_body: Union[str, bytes],
22
+ headers: Mapping[str, str],
23
+ secret: str,
24
+ tolerance_sec: int = SIGNATURE_TOLERANCE_SEC,
25
+ now: Optional[float] = None,
26
+ ) -> bool:
27
+ """Check a diffusr callback (Standard Webhooks).
28
+
29
+ raw_body is the request body exactly as received, before JSON parsing.
30
+ Returns False for a missing header, a bad or stale signature; never raises.
31
+ """
32
+ try:
33
+ msg_id = _header(headers, "webhook-id")
34
+ timestamp = _header(headers, "webhook-timestamp")
35
+ signature = _header(headers, "webhook-signature")
36
+ if not msg_id or not timestamp or not signature or not timestamp.isdigit():
37
+ return False
38
+ current = time.time() if now is None else now
39
+ if abs(int(current) - int(timestamp)) > tolerance_sec:
40
+ return False
41
+ body = raw_body if isinstance(raw_body, (bytes, bytearray)) else raw_body.encode("utf-8")
42
+ key = base64.b64decode(secret[6:] if secret.startswith("whsec_") else secret)
43
+ expected = hmac.new(key, f"{msg_id}.{timestamp}.".encode("utf-8") + bytes(body), hashlib.sha256).digest()
44
+ ok = False
45
+ for part in signature.split(" "):
46
+ if not part.startswith("v1,"):
47
+ continue
48
+ try:
49
+ given = base64.b64decode(part[3:], validate=True)
50
+ except (binascii.Error, ValueError):
51
+ continue
52
+ if hmac.compare_digest(given, expected):
53
+ ok = True
54
+ return ok
55
+ except Exception:
56
+ return False
File without changes
@@ -0,0 +1,336 @@
1
+ import base64
2
+ import hashlib
3
+ import hmac
4
+ import json
5
+ import os
6
+ import sys
7
+ import tempfile
8
+ import threading
9
+ import time
10
+ import unittest
11
+ from http.server import BaseHTTPRequestHandler, HTTPServer
12
+
13
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
14
+
15
+ from diffusr import Diffusr, DiffusrError # noqa: E402
16
+
17
+ VECTORS = os.path.join(os.path.dirname(__file__), "..", "..", "test-vectors", "callbacks.json")
18
+
19
+
20
+ class Mock:
21
+ """A local HTTP server; `handler(req, n)` returns (status, body, headers)."""
22
+
23
+ def __init__(self):
24
+ self.seen = []
25
+ self.handler = lambda req, n: (200, {}, {})
26
+ mock = self
27
+
28
+ class H(BaseHTTPRequestHandler):
29
+ def _do(self):
30
+ length = int(self.headers.get("Content-Length") or 0)
31
+ raw = self.rfile.read(length) if length else b""
32
+ req = {"method": self.command, "path": self.path, "headers": self.headers, "body": raw}
33
+ mock.seen.append(req)
34
+ status, body, headers = mock.handler(req, len(mock.seen))
35
+ payload = b"" if body is None else (body if isinstance(body, bytes) else json.dumps(body).encode())
36
+ self.send_response(status)
37
+ self.send_header("Content-Length", str(len(payload)))
38
+ for k, v in headers.items():
39
+ self.send_header(k, v)
40
+ self.end_headers()
41
+ try:
42
+ self.wfile.write(payload)
43
+ except (BrokenPipeError, ConnectionResetError):
44
+ pass # the client gave up (timeout tests)
45
+
46
+ do_GET = do_POST = do_PUT = _do
47
+
48
+ def log_message(self, *a):
49
+ pass
50
+
51
+ self.server = HTTPServer(("127.0.0.1", 0), H)
52
+ self.root = f"http://127.0.0.1:{self.server.server_port}"
53
+ self.base = self.root + "/api/v2"
54
+ self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
55
+ self.thread.start()
56
+
57
+ def close(self):
58
+ self.server.shutdown()
59
+ self.server.server_close()
60
+
61
+
62
+ def result(status):
63
+ return {"id": "r1", "status": status, "model": "m", "created_at": "now",
64
+ "charge": {"quoted_credits": 5, "charged_credits": 0, "final": False}}
65
+
66
+
67
+ class ClientTests(unittest.TestCase):
68
+ @classmethod
69
+ def setUpClass(cls):
70
+ cls.mock = Mock()
71
+
72
+ @classmethod
73
+ def tearDownClass(cls):
74
+ cls.mock.close()
75
+
76
+ def setUp(self):
77
+ self.mock.seen = []
78
+ self.mock.handler = lambda req, n: (200, {}, {})
79
+
80
+ def client(self, **kw):
81
+ return Diffusr("dfsr_test_abc", self.mock.base, retry_base=0.001, **kw)
82
+
83
+ def test_every_method_hits_the_right_path(self):
84
+ d = self.client()
85
+ cases = [
86
+ (lambda: d.make_video(prompt="p"), "POST", "/make_video"),
87
+ (lambda: d.make_image(prompt="p"), "POST", "/make_image"),
88
+ (lambda: d.edit_image(prompt="p", image_urls=["https://x/a.png"]), "POST", "/edit_image"),
89
+ (lambda: d.recast(video_url="https://x/a.mp4", mode="swap"), "POST", "/recast"),
90
+ (lambda: d.make_music(prompt="p"), "POST", "/make_music"),
91
+ (lambda: d.creator_video(creator_id="c", script="s"), "POST", "/creator_video"),
92
+ (lambda: d.run_template(template="t", prompt="p"), "POST", "/run_template"),
93
+ (lambda: d.get_result("r 1"), "GET", "/results/r%201"),
94
+ (lambda: d.list_results(limit=5), "GET", "/results?limit=5"),
95
+ (lambda: d.cancel("r1"), "POST", "/results/r1/cancel"),
96
+ (lambda: d.list_models(kind="video"), "GET", "/models?kind=video"),
97
+ (lambda: d.list_templates(), "GET", "/templates"),
98
+ (lambda: d.list_callbacks(limit=2, cursor="c"), "GET", "/callbacks?limit=2&cursor=c"),
99
+ (lambda: d.retry_callback("evt_1"), "POST", "/callbacks/evt_1/retry"),
100
+ (lambda: d.get_callback_secret(), "GET", "/callbacks/secret"),
101
+ (lambda: d.rotate_callback_secret(), "POST", "/callbacks/secret/rotate"),
102
+ (lambda: d.upload_from_url("https://x/a.png"), "POST", "/uploads"),
103
+ ]
104
+ for call, method, path in cases:
105
+ with self.subTest(path=path):
106
+ self.mock.seen = []
107
+ call()
108
+ s = self.mock.seen[0]
109
+ self.assertEqual(s["method"], method)
110
+ self.assertEqual(s["path"], "/api/v2" + path)
111
+ self.assertEqual(s["headers"]["Authorization"], "Bearer dfsr_test_abc")
112
+ self.assertTrue(s["headers"]["User-Agent"].startswith("diffusr-python/"))
113
+ if method == "POST":
114
+ self.assertEqual(s["headers"]["Content-Type"], "application/json")
115
+
116
+ def test_body_passes_through_and_idempotency_key_moves_to_header(self):
117
+ self.client().make_video(prompt="p", duration_sec=8, callback_url="https://x/cb", idempotency_key="mine-1")
118
+ s = self.mock.seen[0]
119
+ self.assertEqual(json.loads(s["body"]), {"prompt": "p", "duration_sec": 8, "callback_url": "https://x/cb"})
120
+ self.assertEqual(s["headers"]["Idempotency-Key"], "mine-1")
121
+
122
+ def test_a_key_is_generated_and_differs_per_call(self):
123
+ d = self.client()
124
+ d.make_image(prompt="p")
125
+ d.make_image(prompt="p")
126
+ a, b = (s["headers"]["Idempotency-Key"] for s in self.mock.seen)
127
+ self.assertGreaterEqual(len(a), 8)
128
+ self.assertNotEqual(a, b)
129
+
130
+ def test_dry_run_answer(self):
131
+ self.mock.handler = lambda r, n: (200, {"dry_run": True, "credits_estimated": 12, "note": "n"}, {})
132
+ out = self.client().make_video(prompt="p", dry_run=True)
133
+ self.assertTrue(json.loads(self.mock.seen[0]["body"])["dry_run"])
134
+ self.assertEqual(out["credits_estimated"], 12)
135
+
136
+ def test_env_key(self):
137
+ old = os.environ.pop("DIFFUSR_KEY", None)
138
+ try:
139
+ with self.assertRaises(DiffusrError):
140
+ Diffusr()
141
+ os.environ["DIFFUSR_KEY"] = "dfsr_test_env"
142
+ Diffusr()
143
+ finally:
144
+ os.environ.pop("DIFFUSR_KEY", None)
145
+ if old is not None:
146
+ os.environ["DIFFUSR_KEY"] = old
147
+
148
+ def test_error_mapping(self):
149
+ self.mock.handler = lambda r, n: (
150
+ 402, {"error": "Over the daily limit", "code": "BUDGET_EXCEEDED", "request_id": "req_1", "budget": {"limit": "key"}}, {})
151
+ with self.assertRaises(DiffusrError) as cm:
152
+ self.client().make_image(prompt="p")
153
+ e = cm.exception
154
+ self.assertEqual((e.status, e.code, e.message, e.request_id), (402, "BUDGET_EXCEEDED", "Over the daily limit", "req_1"))
155
+ self.assertEqual(e.body["budget"], {"limit": "key"})
156
+ self.assertEqual(len(self.mock.seen), 1)
157
+
158
+ def test_retry_after_header_and_body_fallback(self):
159
+ self.mock.handler = lambda r, n: (429, {"error": "slow", "code": "CONCURRENCY_LIMIT", "request_id": "req_2"}, {"Retry-After": "15"})
160
+ with self.assertRaises(DiffusrError) as cm:
161
+ self.client(max_retries=0).get_result("r1")
162
+ self.assertEqual(cm.exception.retry_after, 15)
163
+ self.mock.handler = lambda r, n: (503, {"error": "paused", "code": "API_PAUSED", "retry_after_seconds": 300}, {"X-Request-Id": "req_h"})
164
+ with self.assertRaises(DiffusrError) as cm:
165
+ self.client(max_retries=0).get_result("r1")
166
+ self.assertEqual((cm.exception.retry_after, cm.exception.request_id), (300, "req_h"))
167
+
168
+ def test_non_json_error(self):
169
+ self.mock.handler = lambda r, n: (502, b"bad gateway", {})
170
+ with self.assertRaises(DiffusrError) as cm:
171
+ self.client().get_result("r1")
172
+ self.assertEqual((cm.exception.status, cm.exception.code, cm.exception.message), (502, "HTTP_502", "bad gateway"))
173
+
174
+ def test_get_retries_429_then_503(self):
175
+ def h(r, n):
176
+ if n == 1:
177
+ return 429, {"error": "x", "code": "RATE_LIMITED"}, {"Retry-After": "0"}
178
+ if n == 2:
179
+ return 503, {"error": "x", "code": "WALLET_BUSY"}, {}
180
+ return 200, result("PENDING"), {}
181
+ self.mock.handler = h
182
+ self.assertEqual(self.client().get_result("r1")["id"], "r1")
183
+ self.assertEqual(len(self.mock.seen), 3)
184
+
185
+ def test_make_retry_reuses_the_idempotency_key(self):
186
+ def h(r, n):
187
+ if n < 3:
188
+ return 503, {"error": "busy", "code": "WALLET_BUSY"}, {"Retry-After": "0"}
189
+ return 202, {"id": "r1", "status": "PENDING", "credits_estimated": 3, "next": ""}, {}
190
+ self.mock.handler = h
191
+ self.assertEqual(self.client().make_video(prompt="p")["id"], "r1")
192
+ keys = {s["headers"]["Idempotency-Key"] for s in self.mock.seen}
193
+ self.assertEqual(len(self.mock.seen), 3)
194
+ self.assertEqual(len(keys), 1)
195
+
196
+ def test_other_errors_are_not_retried_and_retries_are_bounded(self):
197
+ self.mock.handler = lambda r, n: (422, {"error": "bad", "code": "VALIDATION_FAILED"}, {})
198
+ with self.assertRaises(DiffusrError):
199
+ self.client().make_video(prompt="")
200
+ self.assertEqual(len(self.mock.seen), 1)
201
+ self.mock.seen = []
202
+ self.mock.handler = lambda r, n: (503, {"error": "x", "code": "WALLET_BUSY"}, {"Retry-After": "0"})
203
+ with self.assertRaises(DiffusrError):
204
+ self.client(max_retries=2).get_result("r1")
205
+ self.assertEqual(len(self.mock.seen), 3)
206
+
207
+ def test_long_retry_after_is_not_slept(self):
208
+ self.mock.handler = lambda r, n: (429, {"error": "x", "code": "CONCURRENCY_LIMIT"}, {"Retry-After": "300"})
209
+ with self.assertRaises(DiffusrError) as cm:
210
+ self.client().get_result("r1")
211
+ self.assertEqual(cm.exception.retry_after, 300)
212
+ self.assertEqual(len(self.mock.seen), 1)
213
+
214
+ def test_run_template_posts_flat_template(self):
215
+ self.client().run_template(template="tpl-x", mode="remix", pictures={"him": "https://x/a.png"}, dry_run=True)
216
+ sent = json.loads(self.mock.seen[0]["body"])
217
+ self.assertEqual(sent, {"template": "tpl-x", "mode": "remix", "pictures": {"him": "https://x/a.png"}, "dry_run": True})
218
+
219
+ def test_get_retries_5xx_but_make_does_not(self):
220
+ for status in (500, 502, 504):
221
+ self.mock.seen = []
222
+ self.mock.handler = lambda r, n, s=status: (s, {"error": "x", "code": "BOOM"}, {}) if n < 2 else (200, result("RUNNING"), {})
223
+ self.assertEqual(self.client().get_result("r1")["status"], "RUNNING")
224
+ self.assertEqual(len(self.mock.seen), 2)
225
+ self.mock.seen = []
226
+ self.mock.handler = lambda r, n: (502, {"error": "x", "code": "BOOM"}, {})
227
+ with self.assertRaises(DiffusrError):
228
+ self.client().make_image(prompt="p")
229
+ self.assertEqual(len(self.mock.seen), 1)
230
+
231
+ def test_timeout_code_and_same_idempotency_key(self):
232
+ def slow(r, n):
233
+ time.sleep(0.3)
234
+ return 200, {}, {}
235
+ self.mock.handler = slow
236
+ d = Diffusr("k", self.mock.base, max_retries=1, retry_base=0.001, timeout=0.05)
237
+ with self.assertRaises(DiffusrError) as cm:
238
+ d.make_image(prompt="p")
239
+ self.assertEqual((cm.exception.status, cm.exception.code), (0, "TIMEOUT"))
240
+ time.sleep(0.7) # let the slow handlers finish
241
+ self.assertEqual(len(self.mock.seen), 2)
242
+ keys = {s["headers"]["Idempotency-Key"] for s in self.mock.seen}
243
+ self.assertEqual(len(keys), 1)
244
+
245
+ def test_network_error(self):
246
+ d = Diffusr("k", "http://127.0.0.1:1/api/v2", max_retries=1, retry_base=0.001)
247
+ with self.assertRaises(DiffusrError) as cm:
248
+ d.get_result("r1")
249
+ self.assertEqual((cm.exception.status, cm.exception.code), (0, "NETWORK_ERROR"))
250
+
251
+ # ——— wait_for ———
252
+
253
+ def test_wait_for_terminal(self):
254
+ self.mock.handler = lambda r, n: (200, result("RUNNING" if n < 3 else "SUCCEEDED"), {})
255
+ seen = []
256
+ out = self.client().wait_for("r1", interval=0.005, on_poll=lambda x: seen.append(x["status"]))
257
+ self.assertEqual(out["status"], "SUCCEEDED")
258
+ self.assertEqual(seen, ["RUNNING", "RUNNING", "SUCCEEDED"])
259
+
260
+ def test_wait_for_returns_failed_and_canceled(self):
261
+ for status in ("FAILED", "CANCELED"):
262
+ self.mock.handler = lambda r, n, s=status: (200, result(s), {})
263
+ self.assertEqual(self.client().wait_for("r1", interval=0.001)["status"], status)
264
+
265
+ def test_wait_for_times_out(self):
266
+ self.mock.handler = lambda r, n: (200, result("RUNNING"), {})
267
+ with self.assertRaises(DiffusrError) as cm:
268
+ self.client().wait_for("r1", interval=0.005, timeout=0.06)
269
+ self.assertEqual(cm.exception.code, "WAIT_TIMEOUT")
270
+
271
+ # ——— upload ———
272
+
273
+ def _ticket(self, n):
274
+ return {"upload_url": f"{self.mock.root}/put/{n}", "method": "PUT", "headers": {"Content-Type": "video/mp4"},
275
+ "file_url": "https://media.diffusr.ai/uploads/u/1.mp4", "kind": "video", "expires_in_sec": 900, "curl": "", "next": ""}
276
+
277
+ def test_upload_bytes(self):
278
+ self.mock.handler = lambda r, n: (201, self._ticket(1), {}) if r["path"] == "/api/v2/uploads" else (200, None, {})
279
+ out = self.client().upload_file(b"0123456789", content_type="video/mp4")
280
+ self.assertEqual(out["file_url"], "https://media.diffusr.ai/uploads/u/1.mp4")
281
+ self.assertEqual(json.loads(self.mock.seen[0]["body"]), {"content_type": "video/mp4", "size_bytes": 10})
282
+ put = self.mock.seen[1]
283
+ self.assertEqual((put["method"], put["body"]), ("PUT", b"0123456789"))
284
+ self.assertEqual(put["headers"]["Content-Type"], "video/mp4")
285
+ self.assertEqual(put["headers"]["Content-Length"], "10")
286
+ self.assertIsNone(put["headers"]["Authorization"])
287
+
288
+ def test_upload_path(self):
289
+ self.mock.handler = lambda r, n: (201, self._ticket(2), {}) if r["path"] == "/api/v2/uploads" else (200, None, {})
290
+ with tempfile.TemporaryDirectory() as tmp:
291
+ p = os.path.join(tmp, "clip.mp4")
292
+ with open(p, "wb") as f:
293
+ f.write(b"abc")
294
+ self.client().upload_file(p)
295
+ self.assertEqual(json.loads(self.mock.seen[0]["body"]), {"content_type": "video/mp4", "size_bytes": 3})
296
+
297
+ def test_upload_needs_type_and_reports_failed_put(self):
298
+ with self.assertRaises(DiffusrError) as cm:
299
+ self.client().upload_file(b"x")
300
+ self.assertEqual(cm.exception.code, "CONTENT_TYPE_REQUIRED")
301
+ self.assertEqual(len(self.mock.seen), 0)
302
+ self.mock.handler = lambda r, n: (201, self._ticket(4), {}) if r["path"] == "/api/v2/uploads" else (403, None, {})
303
+ with self.assertRaises(DiffusrError) as cm:
304
+ self.client().upload_file(b"x", content_type="video/mp4")
305
+ self.assertEqual((cm.exception.code, cm.exception.status), ("UPLOAD_FAILED", 403))
306
+
307
+
308
+ class VerifyTests(unittest.TestCase):
309
+ def test_shared_vectors(self):
310
+ with open(VECTORS) as f:
311
+ cases = json.load(f)["cases"]
312
+ self.assertGreater(len(cases), 10)
313
+ for c in cases:
314
+ with self.subTest(c["name"]):
315
+ self.assertEqual(Diffusr.verify_callback(c["body"], c["headers"], c["secret"], now=c["now"]), c["expected"])
316
+
317
+ def test_fresh_signature_real_clock_and_bytes_body(self):
318
+ secret = "whsec_" + base64.b64encode(b"fresh-secret").decode()
319
+ ts = str(int(time.time()))
320
+ body = '{"id":"evt_9"}'
321
+ mac = hmac.new(base64.b64decode(secret[6:]), f"evt_9.{ts}.{body}".encode(), hashlib.sha256).digest()
322
+ headers = {"webhook-id": "evt_9", "webhook-timestamp": ts, "webhook-signature": "v1," + base64.b64encode(mac).decode()}
323
+ self.assertTrue(Diffusr.verify_callback(body, headers, secret))
324
+ self.assertTrue(Diffusr.verify_callback(body.encode(), headers, secret))
325
+ self.assertFalse(Diffusr.verify_callback(body + " ", headers, secret))
326
+ # bytes that are not valid UTF-8 are signed as bytes, never decoded
327
+ raw = b'{"x":"\xff\xfe"}'
328
+ mac = hmac.new(base64.b64decode(secret[6:]), b"evt_9." + ts.encode() + b"." + raw, hashlib.sha256).digest()
329
+ h2 = dict(headers, **{"webhook-signature": "v1," + base64.b64encode(mac).decode()})
330
+ self.assertTrue(Diffusr.verify_callback(raw, h2, secret))
331
+ old = dict(headers, **{"webhook-timestamp": str(int(time.time()) - 3600)})
332
+ self.assertFalse(Diffusr.verify_callback(body, old, secret))
333
+
334
+
335
+ if __name__ == "__main__":
336
+ unittest.main()