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.
@@ -32,3 +32,6 @@ docs/format-refs/format-templates-export/formats/*/refs/*.jpg
32
32
 
33
33
  # Machine notes for Jose's dev boxes (symlinked into worktrees by post-checkout)
34
34
  CLAUDE.local.md*
35
+
36
+ # claude plugin eval output (JOS-465)
37
+ agent-kit/evals/results/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: diffusr
3
- Version: 0.1.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.1.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.1.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.1.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
- raise DiffusrError(0, "MISSING_API_KEY", "Pass an API key or set DIFFUSR_KEY.")
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
- old = os.environ.pop("DIFFUSR_KEY", None)
138
- try:
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
- finally:
144
- os.environ.pop("DIFFUSR_KEY", None)
145
- if old is not None:
146
- os.environ["DIFFUSR_KEY"] = old
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