wavespeed 2.0.2__tar.gz → 2.0.4__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.
- wavespeed-2.0.4/.github/dependabot.yml +11 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/.github/workflows/pre-commit.yml +1 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/PKG-INFO +1 -1
- {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed/_version.py +1 -1
- {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed/api/client.py +27 -30
- {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed.egg-info/PKG-INFO +1 -1
- {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed.egg-info/SOURCES.txt +1 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed.egg-info/scm_file_list.json +1 -0
- wavespeed-2.0.4/src/wavespeed.egg-info/scm_version.json +8 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/tests/test_api.py +108 -0
- wavespeed-2.0.2/src/wavespeed.egg-info/scm_version.json +0 -8
- {wavespeed-2.0.2 → wavespeed-2.0.4}/.github/workflows/claude.yml +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/.github/workflows/python-packages.yml +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/.github/workflows/python-publish.yml +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/.github/workflows/tests.yml +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/.gitignore +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/.pre-commit-config.yaml +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/CHANGELOG.md +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/CLAUDE.md +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/LICENSE +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/MANIFEST.in +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/README.md +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/VERSIONING.md +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/assets/wavespeed-logo-dark.svg +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/assets/wavespeed-logo-light.svg +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/pyproject.toml +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/pytest.ini +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/setup.cfg +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed/__init__.py +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed/_config_module.py +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed/api/__init__.py +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed/config.py +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed.egg-info/dependency_links.txt +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed.egg-info/not-zip-safe +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed.egg-info/requires.txt +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed.egg-info/top_level.txt +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/tests/__init__.py +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/tests/conftest.py +0 -0
- {wavespeed-2.0.2 → wavespeed-2.0.4}/tests/test_config.py +0 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
updates:
|
|
3
|
+
# Actions are pinned to commit SHAs; this keeps the pins moving.
|
|
4
|
+
- package-ecosystem: github-actions
|
|
5
|
+
directory: /
|
|
6
|
+
schedule:
|
|
7
|
+
interval: weekly
|
|
8
|
+
# One pull request per run instead of one per action.
|
|
9
|
+
groups:
|
|
10
|
+
actions:
|
|
11
|
+
patterns: ['*']
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# This file is automatically generated by setuptools_scm
|
|
2
|
-
__version__ = "2.0.
|
|
2
|
+
__version__ = "2.0.4"
|
|
@@ -44,6 +44,10 @@ class _SubmissionError(RuntimeError):
|
|
|
44
44
|
"""A task submission failed and must not be retried automatically."""
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
class _TaskFailedError(RuntimeError):
|
|
48
|
+
"""The task itself reported status ``failed``; a task-level re-run is allowed."""
|
|
49
|
+
|
|
50
|
+
|
|
47
51
|
class Client:
|
|
48
52
|
"""WaveSpeed API client.
|
|
49
53
|
|
|
@@ -332,14 +336,13 @@ class Client:
|
|
|
332
336
|
|
|
333
337
|
if status in ("failed", "cancelled", "timeout", "deleted"):
|
|
334
338
|
error = data.get("error") or "Unknown error"
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
)
|
|
339
|
+
error_cls = _TaskFailedError if status == "failed" else RuntimeError
|
|
340
|
+
raise error_cls(f"Prediction {status} (task_id: {request_id}): {error}")
|
|
338
341
|
|
|
339
342
|
time.sleep(poll_interval)
|
|
340
343
|
|
|
341
344
|
def _is_retryable_error(self, error: Exception) -> bool:
|
|
342
|
-
"""Determine if an error is worth
|
|
345
|
+
"""Determine if an error is worth re-running the task for.
|
|
343
346
|
|
|
344
347
|
Args:
|
|
345
348
|
error: The exception to check.
|
|
@@ -347,29 +350,17 @@ class Client:
|
|
|
347
350
|
Returns:
|
|
348
351
|
True if the error is retryable.
|
|
349
352
|
"""
|
|
350
|
-
#
|
|
351
|
-
#
|
|
352
|
-
|
|
353
|
+
# Only a failure reported by the task itself is re-run, and only when it
|
|
354
|
+
# was a transient upstream error (HTTP 5xx / 429), as before: a
|
|
355
|
+
# deterministic failure such as a content-policy rejection would fail
|
|
356
|
+
# again. Submission errors are ambiguous (the task may already exist),
|
|
357
|
+
# and poll-side HTTP errors, poll retry exhaustion and client-side wait
|
|
358
|
+
# timeouts all happen after the task was created and may be charged, so
|
|
359
|
+
# none of them may turn into another POST.
|
|
360
|
+
if not isinstance(error, _TaskFailedError):
|
|
353
361
|
return False
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
if isinstance(
|
|
357
|
-
error,
|
|
358
|
-
(
|
|
359
|
-
requests.exceptions.Timeout,
|
|
360
|
-
requests.exceptions.ConnectionError,
|
|
361
|
-
TimeoutError,
|
|
362
|
-
),
|
|
363
|
-
):
|
|
364
|
-
return True
|
|
365
|
-
|
|
366
|
-
# Retry server errors (5xx) and rate limiting (429)
|
|
367
|
-
if isinstance(error, RuntimeError):
|
|
368
|
-
error_str = str(error)
|
|
369
|
-
if "HTTP 5" in error_str or "HTTP 429" in error_str:
|
|
370
|
-
return True
|
|
371
|
-
|
|
372
|
-
return False
|
|
362
|
+
error_str = str(error)
|
|
363
|
+
return "HTTP 5" in error_str or "HTTP 429" in error_str
|
|
373
364
|
|
|
374
365
|
def _format_sync_mode_error(self, data: dict[str, Any]) -> str:
|
|
375
366
|
"""Build an actionable error for a non-completed sync-mode response."""
|
|
@@ -464,8 +455,10 @@ class Client:
|
|
|
464
455
|
@staticmethod
|
|
465
456
|
def _task_id_from_error(error: Exception) -> str:
|
|
466
457
|
"""Extract a task ID from an error message, mirroring the JS SDK."""
|
|
467
|
-
match = re.search(r"task_id:\s*([^)]+)", str(error))
|
|
468
|
-
|
|
458
|
+
match = re.search(r"task_id:\s*([^)]+)|for task ([^\s:]+)", str(error))
|
|
459
|
+
if not match:
|
|
460
|
+
return "unknown"
|
|
461
|
+
return (match.group(1) or match.group(2)).strip()
|
|
469
462
|
|
|
470
463
|
def run_no_throw(
|
|
471
464
|
self,
|
|
@@ -587,7 +580,8 @@ class Client:
|
|
|
587
580
|
URL of the uploaded file.
|
|
588
581
|
|
|
589
582
|
Raises:
|
|
590
|
-
ValueError: If API key is not configured
|
|
583
|
+
ValueError: If API key is not configured, or a non-seekable
|
|
584
|
+
stream exceeds the 200 MB upload limit.
|
|
591
585
|
FileNotFoundError: If file path does not exist.
|
|
592
586
|
RuntimeError: If upload fails.
|
|
593
587
|
|
|
@@ -627,7 +621,10 @@ class Client:
|
|
|
627
621
|
size = stream.tell() - start
|
|
628
622
|
stream.seek(start)
|
|
629
623
|
except (AttributeError, OSError, io.UnsupportedOperation):
|
|
630
|
-
|
|
624
|
+
max_size = 200 * 1024 * 1024
|
|
625
|
+
data = stream.read(max_size + 1)
|
|
626
|
+
if len(data) > max_size:
|
|
627
|
+
raise ValueError("File size exceeds the 200 MB limit.")
|
|
631
628
|
stream = io.BytesIO(data)
|
|
632
629
|
size = len(data)
|
|
633
630
|
|
|
@@ -149,6 +149,98 @@ class TestClient(unittest.TestCase):
|
|
|
149
149
|
|
|
150
150
|
mock_post.assert_called_once()
|
|
151
151
|
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _response(status_code, payload=None, text=""):
|
|
154
|
+
response = MagicMock()
|
|
155
|
+
response.status_code = status_code
|
|
156
|
+
response.text = text
|
|
157
|
+
response.json.return_value = payload
|
|
158
|
+
return response
|
|
159
|
+
|
|
160
|
+
@patch("wavespeed.api.client.time.sleep")
|
|
161
|
+
@patch("wavespeed.api.client.requests.get")
|
|
162
|
+
@patch("wavespeed.api.client.requests.post")
|
|
163
|
+
def test_task_retries_do_not_repost_after_poll_retry_exhaustion(
|
|
164
|
+
self, mock_post, mock_get, _mock_sleep
|
|
165
|
+
):
|
|
166
|
+
"""A poll that exhausts its GET retries must not create a second task."""
|
|
167
|
+
mock_post.return_value = self._response(200, {"data": {"id": "req-1"}})
|
|
168
|
+
mock_get.return_value = self._response(502, text="Bad Gateway")
|
|
169
|
+
|
|
170
|
+
client = Client(api_key="test-key", max_retries=2, max_connection_retries=1)
|
|
171
|
+
with self.assertRaises(RuntimeError) as ctx:
|
|
172
|
+
client.run("wavespeed-ai/z-image/turbo", {"prompt": "test"})
|
|
173
|
+
|
|
174
|
+
self.assertIn("req-1", str(ctx.exception))
|
|
175
|
+
self.assertIn("HTTP 502", str(ctx.exception))
|
|
176
|
+
self.assertEqual(mock_post.call_count, 1)
|
|
177
|
+
self.assertEqual(mock_get.call_count, 2)
|
|
178
|
+
|
|
179
|
+
result = client.run_no_throw("wavespeed-ai/z-image/turbo", {"prompt": "test"})
|
|
180
|
+
self.assertEqual(result["task_id"], "req-1")
|
|
181
|
+
self.assertEqual(mock_post.call_count, 2) # one POST per run, never per poll
|
|
182
|
+
|
|
183
|
+
@patch("wavespeed.api.client.time.sleep")
|
|
184
|
+
@patch("wavespeed.api.client.requests.get")
|
|
185
|
+
@patch("wavespeed.api.client.requests.post")
|
|
186
|
+
def test_task_retries_do_not_repost_after_wait_timeout(
|
|
187
|
+
self, mock_post, mock_get, _mock_sleep
|
|
188
|
+
):
|
|
189
|
+
"""A client-side wait timeout leaves the task alive and must not re-POST."""
|
|
190
|
+
mock_post.return_value = self._response(200, {"data": {"id": "req-1"}})
|
|
191
|
+
mock_get.return_value = self._response(200, {"data": {"status": "processing"}})
|
|
192
|
+
|
|
193
|
+
client = Client(api_key="test-key", max_retries=2)
|
|
194
|
+
with self.assertRaises(TimeoutError) as ctx:
|
|
195
|
+
client.run("wavespeed-ai/z-image/turbo", {"prompt": "test"}, timeout=0)
|
|
196
|
+
|
|
197
|
+
self.assertIn("req-1", str(ctx.exception))
|
|
198
|
+
self.assertEqual(mock_post.call_count, 1)
|
|
199
|
+
|
|
200
|
+
@patch("wavespeed.api.client.time.sleep")
|
|
201
|
+
@patch("wavespeed.api.client.requests.get")
|
|
202
|
+
@patch("wavespeed.api.client.requests.post")
|
|
203
|
+
def test_task_retries_rerun_when_task_reports_failed(
|
|
204
|
+
self, mock_post, mock_get, _mock_sleep
|
|
205
|
+
):
|
|
206
|
+
"""A task that fails with a transient upstream error is re-run when max_retries > 0."""
|
|
207
|
+
mock_post.side_effect = [
|
|
208
|
+
self._response(200, {"data": {"id": "req-1"}}),
|
|
209
|
+
self._response(200, {"data": {"id": "req-2"}}),
|
|
210
|
+
]
|
|
211
|
+
mock_get.side_effect = [
|
|
212
|
+
self._response(
|
|
213
|
+
200, {"data": {"status": "failed", "error": "HTTP 500 upstream error"}}
|
|
214
|
+
),
|
|
215
|
+
self._response(200, {"data": {"status": "completed", "outputs": ["url"]}}),
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
client = Client(api_key="test-key", max_retries=1)
|
|
219
|
+
result = client.run("wavespeed-ai/z-image/turbo", {"prompt": "test"})
|
|
220
|
+
|
|
221
|
+
self.assertEqual(result, {"outputs": ["url"]})
|
|
222
|
+
self.assertEqual(mock_post.call_count, 2)
|
|
223
|
+
|
|
224
|
+
@patch("wavespeed.api.client.time.sleep")
|
|
225
|
+
@patch("wavespeed.api.client.requests.get")
|
|
226
|
+
@patch("wavespeed.api.client.requests.post")
|
|
227
|
+
def test_task_retries_do_not_rerun_deterministic_failure(
|
|
228
|
+
self, mock_post, mock_get, _mock_sleep
|
|
229
|
+
):
|
|
230
|
+
"""A task that fails for a non-transient reason is not re-submitted."""
|
|
231
|
+
mock_post.side_effect = [self._response(200, {"data": {"id": "req-1"}})]
|
|
232
|
+
mock_get.side_effect = [
|
|
233
|
+
self._response(
|
|
234
|
+
200, {"data": {"status": "failed", "error": "content policy violation"}}
|
|
235
|
+
),
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
client = Client(api_key="test-key", max_retries=2)
|
|
239
|
+
with self.assertRaises(RuntimeError):
|
|
240
|
+
client.run("wavespeed-ai/z-image/turbo", {"prompt": "test"})
|
|
241
|
+
|
|
242
|
+
self.assertEqual(mock_post.call_count, 1)
|
|
243
|
+
|
|
152
244
|
@patch("wavespeed.api.client.requests.get")
|
|
153
245
|
def test_get_result_success(self, mock_get):
|
|
154
246
|
"""Test successful result retrieval."""
|
|
@@ -582,6 +674,22 @@ class TestUpload(unittest.TestCase):
|
|
|
582
674
|
with self.assertRaises(FileNotFoundError):
|
|
583
675
|
client.upload("/nonexistent/path/to/file.png")
|
|
584
676
|
|
|
677
|
+
@patch("wavespeed.api.client.requests.post")
|
|
678
|
+
def test_upload_oversize_stream_raises(self, mock_post):
|
|
679
|
+
"""A non-seekable stream over the limit fails before any request."""
|
|
680
|
+
|
|
681
|
+
class Pipe:
|
|
682
|
+
name = "clip.mp4"
|
|
683
|
+
|
|
684
|
+
def read(self, n):
|
|
685
|
+
return bytes(n)
|
|
686
|
+
|
|
687
|
+
client = Client(api_key="test-key")
|
|
688
|
+
with self.assertRaises(ValueError) as ctx:
|
|
689
|
+
client.upload(Pipe())
|
|
690
|
+
self.assertIn("200 MB", str(ctx.exception))
|
|
691
|
+
mock_post.assert_not_called()
|
|
692
|
+
|
|
585
693
|
def test_upload_raises_without_api_key(self):
|
|
586
694
|
"""Test that upload raises ValueError without API key."""
|
|
587
695
|
client = Client()
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|