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.
Files changed (39) hide show
  1. wavespeed-2.0.4/.github/dependabot.yml +11 -0
  2. {wavespeed-2.0.2 → wavespeed-2.0.4}/.github/workflows/pre-commit.yml +1 -0
  3. {wavespeed-2.0.2 → wavespeed-2.0.4}/PKG-INFO +1 -1
  4. {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed/_version.py +1 -1
  5. {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed/api/client.py +27 -30
  6. {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed.egg-info/PKG-INFO +1 -1
  7. {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed.egg-info/SOURCES.txt +1 -0
  8. {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed.egg-info/scm_file_list.json +1 -0
  9. wavespeed-2.0.4/src/wavespeed.egg-info/scm_version.json +8 -0
  10. {wavespeed-2.0.2 → wavespeed-2.0.4}/tests/test_api.py +108 -0
  11. wavespeed-2.0.2/src/wavespeed.egg-info/scm_version.json +0 -8
  12. {wavespeed-2.0.2 → wavespeed-2.0.4}/.github/workflows/claude.yml +0 -0
  13. {wavespeed-2.0.2 → wavespeed-2.0.4}/.github/workflows/python-packages.yml +0 -0
  14. {wavespeed-2.0.2 → wavespeed-2.0.4}/.github/workflows/python-publish.yml +0 -0
  15. {wavespeed-2.0.2 → wavespeed-2.0.4}/.github/workflows/tests.yml +0 -0
  16. {wavespeed-2.0.2 → wavespeed-2.0.4}/.gitignore +0 -0
  17. {wavespeed-2.0.2 → wavespeed-2.0.4}/.pre-commit-config.yaml +0 -0
  18. {wavespeed-2.0.2 → wavespeed-2.0.4}/CHANGELOG.md +0 -0
  19. {wavespeed-2.0.2 → wavespeed-2.0.4}/CLAUDE.md +0 -0
  20. {wavespeed-2.0.2 → wavespeed-2.0.4}/LICENSE +0 -0
  21. {wavespeed-2.0.2 → wavespeed-2.0.4}/MANIFEST.in +0 -0
  22. {wavespeed-2.0.2 → wavespeed-2.0.4}/README.md +0 -0
  23. {wavespeed-2.0.2 → wavespeed-2.0.4}/VERSIONING.md +0 -0
  24. {wavespeed-2.0.2 → wavespeed-2.0.4}/assets/wavespeed-logo-dark.svg +0 -0
  25. {wavespeed-2.0.2 → wavespeed-2.0.4}/assets/wavespeed-logo-light.svg +0 -0
  26. {wavespeed-2.0.2 → wavespeed-2.0.4}/pyproject.toml +0 -0
  27. {wavespeed-2.0.2 → wavespeed-2.0.4}/pytest.ini +0 -0
  28. {wavespeed-2.0.2 → wavespeed-2.0.4}/setup.cfg +0 -0
  29. {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed/__init__.py +0 -0
  30. {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed/_config_module.py +0 -0
  31. {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed/api/__init__.py +0 -0
  32. {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed/config.py +0 -0
  33. {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed.egg-info/dependency_links.txt +0 -0
  34. {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed.egg-info/not-zip-safe +0 -0
  35. {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed.egg-info/requires.txt +0 -0
  36. {wavespeed-2.0.2 → wavespeed-2.0.4}/src/wavespeed.egg-info/top_level.txt +0 -0
  37. {wavespeed-2.0.2 → wavespeed-2.0.4}/tests/__init__.py +0 -0
  38. {wavespeed-2.0.2 → wavespeed-2.0.4}/tests/conftest.py +0 -0
  39. {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: ['*']
@@ -24,6 +24,7 @@ jobs:
24
24
  steps:
25
25
  - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
26
26
  with:
27
+ persist-credentials: false
27
28
  path: "."
28
29
  - name: Set up Python
29
30
  uses: actions/setup-python@3542bca2639a428e1796aaa6a2ffef0c0f575566 # v3
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wavespeed
3
- Version: 2.0.2
3
+ Version: 2.0.4
4
4
  Summary: WaveSpeed Python Client — Official Python SDK for the WaveSpeed inference platform
5
5
  Author-email: WaveSpeedAI <support@wavespeed.ai>
6
6
  License: MIT
@@ -1,2 +1,2 @@
1
1
  # This file is automatically generated by setuptools_scm
2
- __version__ = "2.0.2"
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
- raise RuntimeError(
336
- f"Prediction {status} (task_id: {request_id}): {error}"
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 retrying at the task level.
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
- # Submission errors are ambiguous: the server may already have created
351
- # the task, so never turn them into another POST automatically.
352
- if isinstance(error, _SubmissionError):
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
- # Retry timeout and connection errors from result-query GETs.
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
- return match.group(1).strip() if match else "unknown"
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
- data = stream.read(200 * 1024 * 1024 + 1)
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wavespeed
3
- Version: 2.0.2
3
+ Version: 2.0.4
4
4
  Summary: WaveSpeed Python Client — Official Python SDK for the WaveSpeed inference platform
5
5
  Author-email: WaveSpeedAI <support@wavespeed.ai>
6
6
  License: MIT
@@ -9,6 +9,7 @@ VERSIONING.md
9
9
  pyproject.toml
10
10
  pytest.ini
11
11
  setup.cfg
12
+ .github/dependabot.yml
12
13
  .github/workflows/claude.yml
13
14
  .github/workflows/pre-commit.yml
14
15
  .github/workflows/python-packages.yml
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "files": [
3
+ ".github/dependabot.yml",
3
4
  ".github/workflows/claude.yml",
4
5
  ".github/workflows/pre-commit.yml",
5
6
  ".github/workflows/python-packages.yml",
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "2.0.4",
3
+ "distance": 0,
4
+ "node": "g1bf08ac65f5019f75a190187231450423b203238",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-17"
8
+ }
@@ -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()
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "2.0.2",
3
- "distance": 0,
4
- "node": "gac985323c284a8d135a6396b7dc2ac6f9f5e7fd4",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-08-26"
8
- }
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