diffusr 0.1.0__tar.gz → 0.2.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.
- {diffusr-0.1.0 → diffusr-0.2.0}/.gitignore +3 -0
- {diffusr-0.1.0 → diffusr-0.2.0}/PKG-INFO +24 -2
- {diffusr-0.1.0 → diffusr-0.2.0}/README.md +23 -1
- {diffusr-0.1.0 → diffusr-0.2.0}/pyproject.toml +1 -1
- {diffusr-0.1.0 → diffusr-0.2.0}/src/diffusr/__init__.py +4 -1
- {diffusr-0.1.0 → diffusr-0.2.0}/src/diffusr/_client.py +29 -4
- diffusr-0.2.0/src/diffusr/_config.py +29 -0
- {diffusr-0.1.0 → diffusr-0.2.0}/tests/test_client.py +95 -8
- {diffusr-0.1.0 → diffusr-0.2.0}/LICENSE +0 -0
- {diffusr-0.1.0 → diffusr-0.2.0}/src/diffusr/_errors.py +0 -0
- {diffusr-0.1.0 → diffusr-0.2.0}/src/diffusr/_verify.py +0 -0
- {diffusr-0.1.0 → diffusr-0.2.0}/src/diffusr/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: diffusr
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: The diffusr API client: make videos, images and music from code. Free price checks, idempotent starts, wait_for, signed callbacks.
|
|
5
5
|
Project-URL: Homepage, https://diffusr.ai
|
|
6
6
|
Project-URL: Documentation, https://diffusr.ai/docs
|
|
@@ -33,7 +33,7 @@ Mint keys on the Developers page in the app.
|
|
|
33
33
|
```python
|
|
34
34
|
from diffusr import Diffusr
|
|
35
35
|
|
|
36
|
-
diffusr = Diffusr() # reads DIFFUSR_KEY
|
|
36
|
+
diffusr = Diffusr() # reads DIFFUSR_KEY, else the key `npx diffusr login` saved
|
|
37
37
|
|
|
38
38
|
# 1. Ask the price. Free: nothing is made, nothing is spent.
|
|
39
39
|
quote = diffusr.make_video(
|
|
@@ -81,6 +81,28 @@ def hook():
|
|
|
81
81
|
constant-time compare, two signatures after a secret rotation, a 5-minute
|
|
82
82
|
timestamp window, `whsec_` prefix handled.
|
|
83
83
|
|
|
84
|
+
## Command line
|
|
85
|
+
|
|
86
|
+
Install the `diffusr` command with npm (it needs Node 18+) and log in once:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
npx diffusr login # shows a code, opens your browser, saves a key
|
|
90
|
+
npx diffusr whoami
|
|
91
|
+
npx diffusr make_image "a mug on a sunlit table"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
For scripts and agents add `--json` (only the JSON answer on stdout) and
|
|
95
|
+
`--yes` (allow spending; without it nothing is made). `npx diffusr help` lists
|
|
96
|
+
every command.
|
|
97
|
+
|
|
98
|
+
**Where the key is saved.** `~/.config/diffusr/config.json` (or
|
|
99
|
+
`$XDG_CONFIG_HOME/diffusr/config.json`; on Windows `%APPDATA%\diffusr\config.json`).
|
|
100
|
+
Only you can read it. `Diffusr()` uses it when no key is given.
|
|
101
|
+
|
|
102
|
+
**Which key wins.** The `api_key` argument, then the `DIFFUSR_KEY` variable,
|
|
103
|
+
then the saved file. The API address: the `base_url` argument, then
|
|
104
|
+
`DIFFUSR_BASE_URL`, then the saved file's `base_url`.
|
|
105
|
+
|
|
84
106
|
## Methods
|
|
85
107
|
|
|
86
108
|
| Method | REST |
|
|
@@ -18,7 +18,7 @@ Mint keys on the Developers page in the app.
|
|
|
18
18
|
```python
|
|
19
19
|
from diffusr import Diffusr
|
|
20
20
|
|
|
21
|
-
diffusr = Diffusr() # reads DIFFUSR_KEY
|
|
21
|
+
diffusr = Diffusr() # reads DIFFUSR_KEY, else the key `npx diffusr login` saved
|
|
22
22
|
|
|
23
23
|
# 1. Ask the price. Free: nothing is made, nothing is spent.
|
|
24
24
|
quote = diffusr.make_video(
|
|
@@ -66,6 +66,28 @@ def hook():
|
|
|
66
66
|
constant-time compare, two signatures after a secret rotation, a 5-minute
|
|
67
67
|
timestamp window, `whsec_` prefix handled.
|
|
68
68
|
|
|
69
|
+
## Command line
|
|
70
|
+
|
|
71
|
+
Install the `diffusr` command with npm (it needs Node 18+) and log in once:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
npx diffusr login # shows a code, opens your browser, saves a key
|
|
75
|
+
npx diffusr whoami
|
|
76
|
+
npx diffusr make_image "a mug on a sunlit table"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
For scripts and agents add `--json` (only the JSON answer on stdout) and
|
|
80
|
+
`--yes` (allow spending; without it nothing is made). `npx diffusr help` lists
|
|
81
|
+
every command.
|
|
82
|
+
|
|
83
|
+
**Where the key is saved.** `~/.config/diffusr/config.json` (or
|
|
84
|
+
`$XDG_CONFIG_HOME/diffusr/config.json`; on Windows `%APPDATA%\diffusr\config.json`).
|
|
85
|
+
Only you can read it. `Diffusr()` uses it when no key is given.
|
|
86
|
+
|
|
87
|
+
**Which key wins.** The `api_key` argument, then the `DIFFUSR_KEY` variable,
|
|
88
|
+
then the saved file. The API address: the `base_url` argument, then
|
|
89
|
+
`DIFFUSR_BASE_URL`, then the saved file's `base_url`.
|
|
90
|
+
|
|
69
91
|
## Methods
|
|
70
92
|
|
|
71
93
|
| Method | REST |
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "diffusr"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "The diffusr API client: make videos, images and music from code. Free price checks, idempotent starts, wait_for, signed callbacks."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from ._client import DEFAULT_BASE_URL, TERMINAL_STATUSES, Diffusr
|
|
9
|
+
from ._config import config_path, read_config
|
|
9
10
|
from ._errors import DiffusrError
|
|
10
11
|
from ._verify import SIGNATURE_TOLERANCE_SEC, verify_callback
|
|
11
12
|
|
|
@@ -13,8 +14,10 @@ __all__ = [
|
|
|
13
14
|
"Diffusr",
|
|
14
15
|
"DiffusrError",
|
|
15
16
|
"verify_callback",
|
|
17
|
+
"config_path",
|
|
18
|
+
"read_config",
|
|
16
19
|
"DEFAULT_BASE_URL",
|
|
17
20
|
"TERMINAL_STATUSES",
|
|
18
21
|
"SIGNATURE_TOLERANCE_SEC",
|
|
19
22
|
]
|
|
20
|
-
__version__ = "0.
|
|
23
|
+
__version__ = "0.2.0"
|
|
@@ -11,11 +11,12 @@ import urllib.request
|
|
|
11
11
|
import uuid
|
|
12
12
|
from typing import Any, Callable, Dict, Mapping, Optional, Union
|
|
13
13
|
|
|
14
|
+
from ._config import config_path, read_config # noqa: F401
|
|
14
15
|
from ._errors import DiffusrError
|
|
15
16
|
from ._verify import verify_callback as _verify_callback
|
|
16
17
|
|
|
17
18
|
DEFAULT_BASE_URL = "https://app.diffusr.ai/api/v2"
|
|
18
|
-
SDK_VERSION = "0.
|
|
19
|
+
SDK_VERSION = "0.2.0"
|
|
19
20
|
TERMINAL_STATUSES = ("SUCCEEDED", "FAILED", "CANCELED")
|
|
20
21
|
# Never sleep longer than this on one automatic retry; raise instead.
|
|
21
22
|
MAX_AUTO_RETRY_WAIT_SEC = 30
|
|
@@ -30,7 +31,10 @@ _CONTENT_TYPES = {
|
|
|
30
31
|
class Diffusr:
|
|
31
32
|
"""Client for the diffusr REST API (v2). Bodies and answers are the API's own snake_case JSON.
|
|
32
33
|
|
|
33
|
-
api_key dfsr_live_... or dfsr_test_... (free). Default: the DIFFUSR_KEY environment variable
|
|
34
|
+
api_key dfsr_live_... or dfsr_test_... (free). Default: the DIFFUSR_KEY environment variable,
|
|
35
|
+
then the key `npx diffusr login` saved (see config_path()).
|
|
36
|
+
base_url Default: DIFFUSR_BASE_URL, then the saved file's base_url (only when the key came
|
|
37
|
+
from the file), then https://app.diffusr.ai/api/v2.
|
|
34
38
|
"""
|
|
35
39
|
|
|
36
40
|
def __init__(
|
|
@@ -43,10 +47,16 @@ class Diffusr:
|
|
|
43
47
|
timeout: float = 60.0,
|
|
44
48
|
) -> None:
|
|
45
49
|
key = api_key or os.environ.get("DIFFUSR_KEY")
|
|
50
|
+
file_base: Optional[str] = None
|
|
46
51
|
if not key:
|
|
47
|
-
|
|
52
|
+
saved = read_config()
|
|
53
|
+
if saved:
|
|
54
|
+
key = saved["key"]
|
|
55
|
+
file_base = saved.get("base_url") or None
|
|
56
|
+
if not key:
|
|
57
|
+
raise DiffusrError(0, "MISSING_API_KEY", "Pass an API key, set DIFFUSR_KEY, or run `npx diffusr login`.")
|
|
48
58
|
self._key = key
|
|
49
|
-
self._base = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
59
|
+
self._base = (base_url or os.environ.get("DIFFUSR_BASE_URL") or file_base or DEFAULT_BASE_URL).rstrip("/")
|
|
50
60
|
self._max_retries = max_retries
|
|
51
61
|
self._retry_base = retry_base
|
|
52
62
|
self._timeout = timeout
|
|
@@ -191,6 +201,21 @@ class Diffusr:
|
|
|
191
201
|
def rotate_callback_secret(self) -> Dict[str, Any]:
|
|
192
202
|
return self._request("POST", "/callbacks/secret/rotate", body={})
|
|
193
203
|
|
|
204
|
+
def call(
|
|
205
|
+
self,
|
|
206
|
+
method: str,
|
|
207
|
+
path: str,
|
|
208
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
209
|
+
body: Optional[Dict[str, Any]] = None,
|
|
210
|
+
headers: Optional[Dict[str, str]] = None,
|
|
211
|
+
) -> Dict[str, Any]:
|
|
212
|
+
"""Call any API path the typed methods do not cover, e.g. call("GET", "/me").
|
|
213
|
+
Same retries and errors as every other call. Add your own Idempotency-Key
|
|
214
|
+
header for a make call."""
|
|
215
|
+
q = {k: (str(v).lower() if isinstance(v, bool) else v) for k, v in (query or {}).items() if v is not None}
|
|
216
|
+
suffix = f"?{urllib.parse.urlencode(q)}" if q else ""
|
|
217
|
+
return self._request(method, path + suffix, body=body, headers=headers)
|
|
218
|
+
|
|
194
219
|
@staticmethod
|
|
195
220
|
def verify_callback(
|
|
196
221
|
raw_body: Union[str, bytes],
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""The key `npx diffusr login` saves, so Python code can use it too."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def config_path() -> str:
|
|
12
|
+
"""$XDG_CONFIG_HOME/diffusr/config.json, else ~/.config/diffusr/config.json; on Windows %APPDATA%\\diffusr\\config.json."""
|
|
13
|
+
if sys.platform == "win32":
|
|
14
|
+
base = os.environ.get("APPDATA") or os.path.join(os.path.expanduser("~"), "AppData", "Roaming")
|
|
15
|
+
else:
|
|
16
|
+
base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
|
|
17
|
+
return os.path.join(base, "diffusr", "config.json")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def read_config() -> Optional[Dict[str, Any]]:
|
|
21
|
+
"""The saved config (it has a "key"), or None when there is none. Never raises."""
|
|
22
|
+
try:
|
|
23
|
+
with open(config_path(), "r", encoding="utf-8") as f:
|
|
24
|
+
data = json.load(f)
|
|
25
|
+
except (OSError, ValueError):
|
|
26
|
+
return None
|
|
27
|
+
if isinstance(data, dict) and isinstance(data.get("key"), str) and data["key"]:
|
|
28
|
+
return data
|
|
29
|
+
return None
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import base64
|
|
2
|
+
import contextlib
|
|
2
3
|
import hashlib
|
|
3
4
|
import hmac
|
|
4
5
|
import json
|
|
@@ -12,11 +13,37 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
|
12
13
|
|
|
13
14
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
14
15
|
|
|
15
|
-
from diffusr import Diffusr, DiffusrError # noqa: E402
|
|
16
|
+
from diffusr import Diffusr, DiffusrError, config_path # noqa: E402
|
|
16
17
|
|
|
17
18
|
VECTORS = os.path.join(os.path.dirname(__file__), "..", "..", "test-vectors", "callbacks.json")
|
|
18
19
|
|
|
19
20
|
|
|
21
|
+
@contextlib.contextmanager
|
|
22
|
+
def isolated_env():
|
|
23
|
+
"""A temp HOME with no DIFFUSR_* or XDG_CONFIG_HOME set, so a real saved key never leaks in."""
|
|
24
|
+
names = ["HOME", "APPDATA", "XDG_CONFIG_HOME", "DIFFUSR_KEY", "DIFFUSR_BASE_URL"]
|
|
25
|
+
old = {n: os.environ.get(n) for n in names}
|
|
26
|
+
with tempfile.TemporaryDirectory() as home:
|
|
27
|
+
for n in names:
|
|
28
|
+
os.environ.pop(n, None)
|
|
29
|
+
os.environ["HOME"] = home
|
|
30
|
+
try:
|
|
31
|
+
yield home
|
|
32
|
+
finally:
|
|
33
|
+
for n, v in old.items():
|
|
34
|
+
if v is None:
|
|
35
|
+
os.environ.pop(n, None)
|
|
36
|
+
else:
|
|
37
|
+
os.environ[n] = v
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save_config(home, data):
|
|
41
|
+
path = os.path.join(home, ".config", "diffusr")
|
|
42
|
+
os.makedirs(path, exist_ok=True)
|
|
43
|
+
with open(os.path.join(path, "config.json"), "w") as f:
|
|
44
|
+
json.dump(data, f)
|
|
45
|
+
|
|
46
|
+
|
|
20
47
|
class Mock:
|
|
21
48
|
"""A local HTTP server; `handler(req, n)` returns (status, body, headers)."""
|
|
22
49
|
|
|
@@ -134,16 +161,45 @@ class ClientTests(unittest.TestCase):
|
|
|
134
161
|
self.assertEqual(out["credits_estimated"], 12)
|
|
135
162
|
|
|
136
163
|
def test_env_key(self):
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
with self.assertRaises(DiffusrError):
|
|
164
|
+
with isolated_env() as home:
|
|
165
|
+
with self.assertRaises(DiffusrError) as cm:
|
|
140
166
|
Diffusr()
|
|
167
|
+
self.assertIn("npx diffusr login", str(cm.exception))
|
|
141
168
|
os.environ["DIFFUSR_KEY"] = "dfsr_test_env"
|
|
142
169
|
Diffusr()
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
170
|
+
|
|
171
|
+
def test_saved_key_lookup_order(self):
|
|
172
|
+
with isolated_env() as home:
|
|
173
|
+
save_config(home, {"key": "dfsr_live_file", "base_url": "http://file.example/api/v2"})
|
|
174
|
+
d = Diffusr()
|
|
175
|
+
self.assertEqual(d._key, "dfsr_live_file")
|
|
176
|
+
self.assertEqual(d._base, "http://file.example/api/v2")
|
|
177
|
+
os.environ["DIFFUSR_KEY"] = "dfsr_live_env"
|
|
178
|
+
d = Diffusr()
|
|
179
|
+
self.assertEqual(d._key, "dfsr_live_env")
|
|
180
|
+
self.assertEqual(d._base, "https://app.diffusr.ai/api/v2") # the file's base_url goes with the file's key only
|
|
181
|
+
self.assertEqual(Diffusr("dfsr_live_arg")._key, "dfsr_live_arg")
|
|
182
|
+
|
|
183
|
+
def test_base_url_order(self):
|
|
184
|
+
with isolated_env() as home:
|
|
185
|
+
save_config(home, {"key": "dfsr_live_file", "base_url": "http://file.example/api/v2"})
|
|
186
|
+
os.environ["DIFFUSR_BASE_URL"] = "http://env.example/api/v2/"
|
|
187
|
+
self.assertEqual(Diffusr()._base, "http://env.example/api/v2")
|
|
188
|
+
self.assertEqual(Diffusr(base_url="http://arg.example/api/v2")._base, "http://arg.example/api/v2")
|
|
189
|
+
|
|
190
|
+
def test_xdg_config_home_and_bad_file(self):
|
|
191
|
+
with isolated_env() as home:
|
|
192
|
+
xdg = os.path.join(home, "xdg")
|
|
193
|
+
os.environ["XDG_CONFIG_HOME"] = xdg
|
|
194
|
+
self.assertEqual(config_path(), os.path.join(xdg, "diffusr", "config.json"))
|
|
195
|
+
os.makedirs(os.path.join(xdg, "diffusr"))
|
|
196
|
+
with open(config_path(), "w") as f:
|
|
197
|
+
f.write("not json")
|
|
198
|
+
with self.assertRaises(DiffusrError):
|
|
199
|
+
Diffusr()
|
|
200
|
+
with open(config_path(), "w") as f:
|
|
201
|
+
json.dump({"key": "dfsr_live_x"}, f)
|
|
202
|
+
self.assertEqual(Diffusr()._key, "dfsr_live_x")
|
|
147
203
|
|
|
148
204
|
def test_error_mapping(self):
|
|
149
205
|
self.mock.handler = lambda r, n: (
|
|
@@ -334,3 +390,34 @@ class VerifyTests(unittest.TestCase):
|
|
|
334
390
|
|
|
335
391
|
if __name__ == "__main__":
|
|
336
392
|
unittest.main()
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
from unittest import mock # noqa: E402
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class CallTests(unittest.TestCase):
|
|
399
|
+
def test_call_builds_the_path_and_query(self):
|
|
400
|
+
seen = {}
|
|
401
|
+
|
|
402
|
+
def fake(req, timeout=None):
|
|
403
|
+
seen["url"] = req.full_url
|
|
404
|
+
seen["method"] = req.get_method()
|
|
405
|
+
|
|
406
|
+
class R:
|
|
407
|
+
def read(self):
|
|
408
|
+
return b'{"email": "a@b.c"}'
|
|
409
|
+
|
|
410
|
+
def __enter__(self):
|
|
411
|
+
return self
|
|
412
|
+
|
|
413
|
+
def __exit__(self, *a):
|
|
414
|
+
return False
|
|
415
|
+
|
|
416
|
+
return R()
|
|
417
|
+
|
|
418
|
+
d = Diffusr("dfsr_test_" + "0" * 48, base_url="https://x.test/api/v2")
|
|
419
|
+
with mock.patch("urllib.request.urlopen", fake):
|
|
420
|
+
out = d.call("GET", "/me", query={"limit": 5, "skip": None, "flag": True})
|
|
421
|
+
self.assertEqual(out, {"email": "a@b.c"})
|
|
422
|
+
self.assertEqual(seen["method"], "GET")
|
|
423
|
+
self.assertEqual(seen["url"], "https://x.test/api/v2/me?limit=5&flag=true")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|