From 0cccbf5b0481e4c11a28e80200cc1cceb5e393f4 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 12 Jul 2026 01:19:43 -0700 Subject: [PATCH 01/18] feat: SFX types (SfxTask/SfxMedia/SfxResult) and task errors --- src/sonilo/__init__.py | 18 ++++++++- src/sonilo/errors.py | 26 +++++++++++++ src/sonilo/types.py | 70 +++++++++++++++++++++++++++++++++ tests/test_sfx_types.py | 86 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 tests/test_sfx_types.py diff --git a/src/sonilo/__init__.py b/src/sonilo/__init__.py index 5d872cf..c90d9e5 100644 --- a/src/sonilo/__init__.py +++ b/src/sonilo/__init__.py @@ -9,8 +9,18 @@ PaymentRequiredError, RateLimitError, SoniloError, + TaskFailedError, + TaskTimeoutError, +) +from sonilo.types import ( + Segment, + SfxMedia, + SfxResult, + SfxSegment, + SfxTask, + StreamEvent, + Track, ) -from sonilo.types import Segment, StreamEvent, Track __all__ = [ "APIError", @@ -21,9 +31,15 @@ "PaymentRequiredError", "RateLimitError", "Segment", + "SfxMedia", + "SfxResult", + "SfxSegment", + "SfxTask", "Sonilo", "SoniloError", "StreamEvent", + "TaskFailedError", + "TaskTimeoutError", "Track", "__version__", ] diff --git a/src/sonilo/errors.py b/src/sonilo/errors.py index 773008b..4b2b32f 100644 --- a/src/sonilo/errors.py +++ b/src/sonilo/errors.py @@ -54,6 +54,32 @@ def __init__(self, message: str, code: Optional[str] = None) -> None: self.code = code +class TaskFailedError(SoniloError): + """Raised by tasks.wait()/generate() when an SFX task reaches `failed`.""" + + def __init__( + self, + message: str, + *, + code: Optional[str] = None, + task_id: Optional[str] = None, + refunded: Optional[bool] = None, + ) -> None: + super().__init__(message) + self.code = code + self.task_id = task_id + self.refunded = refunded + + +class TaskTimeoutError(SoniloError): + """Poll deadline passed. The task may still finish server-side — resume + with tasks.wait(task_id) or tasks.get(task_id).""" + + def __init__(self, message: str, *, task_id: Optional[str] = None) -> None: + super().__init__(message) + self.task_id = task_id + + def error_from_response(response: httpx.Response) -> APIError: """Map a non-2xx response to a typed error. diff --git a/src/sonilo/types.py b/src/sonilo/types.py index 95342fb..07f6678 100644 --- a/src/sonilo/types.py +++ b/src/sonilo/types.py @@ -1,15 +1,22 @@ from __future__ import annotations +import httpx from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, Optional, Union +from sonilo.errors import SoniloError + Segment = Dict[str, Any] """{"start": float, "prompt": str, "label": optional str}""" StreamEvent = Dict[str, Any] """One NDJSON event; audio_chunk events carry `data` as decoded bytes.""" +SfxSegment = Dict[str, Any] +"""{"start": float, "end": float, "prompt": str} — SFX segments (unlike music +Segment) require `end`, must start at 0, and be contiguous; validated server-side.""" + @dataclass class Track: @@ -22,3 +29,66 @@ def save(self, path: Union[str, Path]) -> Path: p = Path(path) p.write_bytes(self.audio) return p + + +@dataclass +class SfxTask: + """Submission ack for the async SFX endpoints.""" + + task_id: str + status: str + + +@dataclass +class SfxMedia: + """A generated file re-hosted on R2 behind a presigned URL.""" + + url: str + content_type: Optional[str] = None + file_size: Optional[int] = None + + +@dataclass +class SfxResult: + """State of an SFX task (`tasks.get`) or its final result (`wait`/`generate`).""" + + task_id: str + status: str + type: Optional[str] = None + audio: Optional[SfxMedia] = None + video: Optional[SfxMedia] = None + cost: Optional[float] = None + error: Optional[Dict[str, Any]] = None + refunded: Optional[bool] = None + + def _media(self, which: str) -> SfxMedia: + if which not in ("audio", "video"): + raise SoniloError('which must be "audio" or "video"') + media = getattr(self, which) + if media is None: + raise SoniloError(f"No {which} on this result (status={self.status})") + return media + + def save(self, path: Union[str, Path], *, which: str = "audio") -> Path: + """Download the audio (or video) to `path` and return it. + + The URL is presigned — no API key is sent. + """ + media = self._media(which) + response = httpx.get(media.url, follow_redirects=True) + if response.status_code >= 400: + raise SoniloError(f"Download failed: HTTP {response.status_code}") + p = Path(path) + p.write_bytes(response.content) + return p + + async def asave(self, path: Union[str, Path], *, which: str = "audio") -> Path: + """Async variant of save().""" + media = self._media(which) + async with httpx.AsyncClient(follow_redirects=True) as http: + response = await http.get(media.url) + if response.status_code >= 400: + raise SoniloError(f"Download failed: HTTP {response.status_code}") + p = Path(path) + p.write_bytes(response.content) + return p diff --git a/tests/test_sfx_types.py b/tests/test_sfx_types.py new file mode 100644 index 0000000..0e7a446 --- /dev/null +++ b/tests/test_sfx_types.py @@ -0,0 +1,86 @@ +import httpx +import pytest +import respx + +from sonilo import ( + SfxMedia, + SfxResult, + SfxTask, + SoniloError, + TaskFailedError, + TaskTimeoutError, +) + +AUDIO = SfxMedia(url="https://r2.example.com/audio.m4a", content_type="audio/mp4", file_size=10) + + +def make_result(**overrides) -> SfxResult: + kwargs = {"task_id": "t1", "status": "succeeded", "audio": AUDIO} + kwargs.update(overrides) + return SfxResult(**kwargs) + + +def test_sfx_task_fields(): + task = SfxTask(task_id="t1", status="processing") + assert task.task_id == "t1" + assert task.status == "processing" + + +@respx.mock +def test_save_downloads_audio(tmp_path): + respx.get("https://r2.example.com/audio.m4a").mock( + return_value=httpx.Response(200, content=b"audiobytes") + ) + out = make_result().save(tmp_path / "out.m4a") + assert out.read_bytes() == b"audiobytes" + + +@respx.mock +def test_save_which_video(tmp_path): + respx.get("https://r2.example.com/video.mp4").mock( + return_value=httpx.Response(200, content=b"videobytes") + ) + result = make_result(video=SfxMedia(url="https://r2.example.com/video.mp4")) + out = result.save(tmp_path / "out.mp4", which="video") + assert out.read_bytes() == b"videobytes" + + +def test_save_missing_media_raises(tmp_path): + result = SfxResult(task_id="t1", status="processing") + with pytest.raises(SoniloError): + result.save(tmp_path / "out.m4a") + + +def test_save_rejects_unknown_which(tmp_path): + with pytest.raises(SoniloError): + make_result().save(tmp_path / "x", which="cover_art") + + +@respx.mock +def test_save_download_http_error_raises(tmp_path): + respx.get("https://r2.example.com/audio.m4a").mock( + return_value=httpx.Response(403, content=b"expired") + ) + with pytest.raises(SoniloError): + make_result().save(tmp_path / "out.m4a") + + +@respx.mock +async def test_asave_downloads_audio(tmp_path): + respx.get("https://r2.example.com/audio.m4a").mock( + return_value=httpx.Response(200, content=b"audiobytes") + ) + out = await make_result().asave(tmp_path / "out.m4a") + assert out.read_bytes() == b"audiobytes" + + +def test_task_errors_carry_fields(): + failed = TaskFailedError("boom", code="GENERATION_FAILED", task_id="t1", refunded=True) + assert isinstance(failed, SoniloError) + assert failed.code == "GENERATION_FAILED" + assert failed.task_id == "t1" + assert failed.refunded is True + + timed_out = TaskTimeoutError("slow", task_id="t1") + assert isinstance(timed_out, SoniloError) + assert timed_out.task_id == "t1" From df40bc9d6f30b15208709672a0d637ad8e25db5c Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 12 Jul 2026 01:23:45 -0700 Subject: [PATCH 02/18] feat: SFX request builders --- src/sonilo/_requests.py | 22 +++++++++++++++++ tests/test_sfx.py | 54 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 tests/test_sfx.py diff --git a/src/sonilo/_requests.py b/src/sonilo/_requests.py index 19a6905..14a153b 100644 --- a/src/sonilo/_requests.py +++ b/src/sonilo/_requests.py @@ -64,3 +64,25 @@ def build_v2m_parts( files = {"video": (filename, fileobj, "video/mp4")} return data, files, opened + + +def build_sfx_t2s_data( + prompt: str, duration: int, audio_format: Optional[str] +) -> Dict[str, str]: + data = {"prompt": prompt, "duration": str(duration)} + if audio_format is not None: + data["audio_format"] = audio_format + return data + + +def build_sfx_v2s_parts( + video: Any, + video_url: Optional[str], + prompt: Optional[str], + segments: Optional[List[Segment]], + audio_format: Optional[str], +) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]: + data, files, opened = build_v2m_parts(video, video_url, prompt, segments) + if audio_format is not None: + data["audio_format"] = audio_format + return data, files, opened diff --git a/tests/test_sfx.py b/tests/test_sfx.py new file mode 100644 index 0000000..f2adefa --- /dev/null +++ b/tests/test_sfx.py @@ -0,0 +1,54 @@ +import json + +import httpx +import pytest +import respx + +# import sonilo.resources.tasks as tasks_module # restored in Task 3 +from sonilo import Sonilo +from sonilo._requests import build_sfx_t2s_data, build_sfx_v2s_parts +from sonilo.errors import SoniloError + +BASE = "https://api.sonilo.com" + +SUCCEEDED = { + "task_id": "t1", + "type": "text_to_sfx", + "status": "succeeded", + "audio": { + "url": "https://r2.example.com/audio.m4a", + "content_type": "audio/mp4", + "file_size": 123, + }, +} + + +def make_client() -> Sonilo: + return Sonilo(api_key="sk_test_123") + + +def test_build_sfx_t2s_data(): + assert build_sfx_t2s_data("boom", 5, None) == {"prompt": "boom", "duration": "5"} + assert build_sfx_t2s_data("boom", 5, "wav") == { + "prompt": "boom", + "duration": "5", + "audio_format": "wav", + } + + +def test_build_sfx_v2s_parts_serializes_segments_and_format(): + segments = [{"start": 0, "end": 2.5, "prompt": "glass"}] + data, files, opened = build_sfx_v2s_parts( + None, "https://e.com/v.mp4", None, segments, "mp3" + ) + assert files is None and opened is False + assert data["video_url"] == "https://e.com/v.mp4" + assert data["audio_format"] == "mp3" + assert json.loads(data["segments"]) == segments + + +def test_build_sfx_v2s_parts_requires_exactly_one_source(): + with pytest.raises(SoniloError): + build_sfx_v2s_parts(None, None, None, None, None) + with pytest.raises(SoniloError): + build_sfx_v2s_parts(b"x", "https://e.com/v.mp4", None, None, None) From d015b00833848dbcfcaacdaf29489b7c498df270 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 12 Jul 2026 01:27:35 -0700 Subject: [PATCH 03/18] feat: tasks resource with get/wait polling --- src/sonilo/_async_client.py | 2 + src/sonilo/_client.py | 2 + src/sonilo/resources/tasks.py | 123 ++++++++++++++++++++++++++++++++++ tests/test_sfx.py | 98 ++++++++++++++++++++++++++- 4 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 src/sonilo/resources/tasks.py diff --git a/src/sonilo/_async_client.py b/src/sonilo/_async_client.py index 9321462..50aec1d 100644 --- a/src/sonilo/_async_client.py +++ b/src/sonilo/_async_client.py @@ -8,6 +8,7 @@ from sonilo._streaming import aiter_events from sonilo.errors import error_from_response from sonilo.resources.account import AsyncAccount +from sonilo.resources.tasks import AsyncTasks from sonilo.resources.text_to_music import AsyncTextToMusic from sonilo.resources.video_to_music import AsyncVideoToMusic from sonilo.types import StreamEvent @@ -31,6 +32,7 @@ def __init__( self.text_to_music = AsyncTextToMusic(self) self.video_to_music = AsyncVideoToMusic(self) self.account = AsyncAccount(self) + self.tasks = AsyncTasks(self) async def close(self) -> None: await self._http.aclose() diff --git a/src/sonilo/_client.py b/src/sonilo/_client.py index 2cfafdc..70daa80 100644 --- a/src/sonilo/_client.py +++ b/src/sonilo/_client.py @@ -9,6 +9,7 @@ from sonilo._version import __version__ from sonilo.errors import SoniloError, error_from_response from sonilo.resources.account import Account +from sonilo.resources.tasks import Tasks from sonilo.resources.text_to_music import TextToMusic from sonilo.resources.video_to_music import VideoToMusic from sonilo.types import StreamEvent @@ -52,6 +53,7 @@ def __init__( self.text_to_music = TextToMusic(self) self.video_to_music = VideoToMusic(self) self.account = Account(self) + self.tasks = Tasks(self) def close(self) -> None: self._http.close() diff --git a/src/sonilo/resources/tasks.py b/src/sonilo/resources/tasks.py new file mode 100644 index 0000000..01d58da --- /dev/null +++ b/src/sonilo/resources/tasks.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import asyncio +import time +from typing import TYPE_CHECKING, Any, Dict, Optional + +from sonilo.errors import TaskFailedError, TaskTimeoutError +from sonilo.types import SfxMedia, SfxResult, SfxTask + +if TYPE_CHECKING: + from sonilo._async_client import AsyncSonilo + from sonilo._client import Sonilo + +DEFAULT_POLL_INTERVAL = 2.0 +DEFAULT_WAIT_TIMEOUT = 600.0 + +# Test seams: monkeypatched in tests so polling is instant and deterministic. +_sleep = time.sleep +_async_sleep = asyncio.sleep +_monotonic = time.monotonic + + +def _media_from(data: Any) -> Optional[SfxMedia]: + if not isinstance(data, dict) or "url" not in data: + return None + return SfxMedia( + url=data["url"], + content_type=data.get("content_type"), + file_size=data.get("file_size"), + ) + + +def parse_sfx_result(body: Dict[str, Any]) -> SfxResult: + """Map a GET /v1/tasks/{id} body to SfxResult; unknown fields are ignored.""" + return SfxResult( + task_id=body["task_id"], + status=body["status"], + type=body.get("type"), + audio=_media_from(body.get("audio")), + video=_media_from(body.get("video")), + cost=body.get("cost"), + error=body.get("error"), + refunded=body.get("refunded"), + ) + + +def parse_sfx_task(body: Dict[str, Any]) -> SfxTask: + """Map a submission ack to SfxTask.""" + return SfxTask(task_id=body["task_id"], status=body.get("status", "processing")) + + +def _raise_if_failed(result: SfxResult) -> None: + if result.status == "failed": + error = result.error or {} + message = error.get("message") or "Generation failed" + raise TaskFailedError( + f"Task {result.task_id} failed: {message}", + code=error.get("code"), + task_id=result.task_id, + refunded=result.refunded, + ) + + +def _timeout_error(task_id: str, timeout: float) -> TaskTimeoutError: + return TaskTimeoutError( + f"Task {task_id} still processing after {timeout:.0f}s; " + "it may finish later — resume with tasks.wait or tasks.get", + task_id=task_id, + ) + + +class Tasks: + def __init__(self, client: "Sonilo") -> None: + self._client = client + + def get(self, task_id: str) -> SfxResult: + """Fetch current task state. Never raises on a failed status.""" + return parse_sfx_result(self._client._get_json(f"/v1/tasks/{task_id}")) + + def wait( + self, + task_id: str, + *, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + ) -> SfxResult: + """Poll until the task is terminal; raise on failure or deadline.""" + deadline = _monotonic() + timeout + while True: + result = self.get(task_id) + if result.status == "succeeded": + return result + _raise_if_failed(result) + if _monotonic() >= deadline: + raise _timeout_error(task_id, timeout) + _sleep(poll_interval) + + +class AsyncTasks: + def __init__(self, client: "AsyncSonilo") -> None: + self._client = client + + async def get(self, task_id: str) -> SfxResult: + """Fetch current task state. Never raises on a failed status.""" + return parse_sfx_result(await self._client._get_json(f"/v1/tasks/{task_id}")) + + async def wait( + self, + task_id: str, + *, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + ) -> SfxResult: + """Poll until the task is terminal; raise on failure or deadline.""" + deadline = _monotonic() + timeout + while True: + result = await self.get(task_id) + if result.status == "succeeded": + return result + _raise_if_failed(result) + if _monotonic() >= deadline: + raise _timeout_error(task_id, timeout) + await _async_sleep(poll_interval) diff --git a/tests/test_sfx.py b/tests/test_sfx.py index f2adefa..b66d015 100644 --- a/tests/test_sfx.py +++ b/tests/test_sfx.py @@ -4,10 +4,11 @@ import pytest import respx -# import sonilo.resources.tasks as tasks_module # restored in Task 3 +import sonilo.resources.tasks as tasks_module # restored in Task 3 from sonilo import Sonilo from sonilo._requests import build_sfx_t2s_data, build_sfx_v2s_parts -from sonilo.errors import SoniloError +from sonilo.errors import SoniloError, TaskFailedError, TaskTimeoutError +from sonilo.types import SfxMedia BASE = "https://api.sonilo.com" @@ -52,3 +53,96 @@ def test_build_sfx_v2s_parts_requires_exactly_one_source(): build_sfx_v2s_parts(None, None, None, None, None) with pytest.raises(SoniloError): build_sfx_v2s_parts(b"x", "https://e.com/v.mp4", None, None, None) + + +@respx.mock +def test_tasks_get_parses_succeeded(): + respx.get(f"{BASE}/v1/tasks/t1").mock(return_value=httpx.Response(200, json=SUCCEEDED)) + with make_client() as client: + result = client.tasks.get("t1") + assert result.status == "succeeded" + assert result.type == "text_to_sfx" + assert result.audio == SfxMedia( + url="https://r2.example.com/audio.m4a", content_type="audio/mp4", file_size=123 + ) + assert result.video is None + assert result.cost is None + + +@respx.mock +def test_tasks_get_returns_failed_as_data(): + respx.get(f"{BASE}/v1/tasks/t1").mock( + return_value=httpx.Response( + 200, + json={ + "task_id": "t1", + "type": "text_to_sfx", + "status": "failed", + "error": {"code": "UPSTREAM_MALFORMED", "message": "boom"}, + "refunded": True, + }, + ) + ) + with make_client() as client: + result = client.tasks.get("t1") + assert result.status == "failed" + assert result.error == {"code": "UPSTREAM_MALFORMED", "message": "boom"} + assert result.refunded is True + + +@respx.mock +def test_tasks_wait_polls_until_succeeded(monkeypatch): + sleeps = [] + monkeypatch.setattr(tasks_module, "_sleep", sleeps.append) + processing = {"task_id": "t1", "type": "text_to_sfx", "status": "processing"} + respx.get(f"{BASE}/v1/tasks/t1").mock( + side_effect=[ + httpx.Response(200, json=processing), + httpx.Response(200, json=processing), + httpx.Response(200, json=SUCCEEDED), + ] + ) + with make_client() as client: + result = client.tasks.wait("t1") + assert result.status == "succeeded" + assert sleeps == [2.0, 2.0] + + +@respx.mock +def test_tasks_wait_raises_task_failed(monkeypatch): + monkeypatch.setattr(tasks_module, "_sleep", lambda s: None) + respx.get(f"{BASE}/v1/tasks/t1").mock( + return_value=httpx.Response( + 200, + json={ + "task_id": "t1", + "status": "failed", + "error": {"code": "GENERATION_FAILED", "message": "boom"}, + "refunded": True, + }, + ) + ) + with make_client() as client: + with pytest.raises(TaskFailedError) as exc_info: + client.tasks.wait("t1") + assert exc_info.value.code == "GENERATION_FAILED" + assert exc_info.value.task_id == "t1" + assert exc_info.value.refunded is True + + +@respx.mock +def test_tasks_wait_times_out(monkeypatch): + clock = {"t": 0.0} + monkeypatch.setattr(tasks_module, "_monotonic", lambda: clock["t"]) + + def advance(seconds): + clock["t"] += seconds + + monkeypatch.setattr(tasks_module, "_sleep", advance) + respx.get(f"{BASE}/v1/tasks/t1").mock( + return_value=httpx.Response(200, json={"task_id": "t1", "status": "processing"}) + ) + with make_client() as client: + with pytest.raises(TaskTimeoutError) as exc_info: + client.tasks.wait("t1", poll_interval=1.0, timeout=3.0) + assert exc_info.value.task_id == "t1" From a54638aeace6b31573c3b7502413183003ce0b57 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 12 Jul 2026 01:32:50 -0700 Subject: [PATCH 04/18] feat: text-to-sfx and video-to-sfx resources with generate() --- src/sonilo/_async_client.py | 21 ++++++ src/sonilo/_client.py | 21 ++++++ src/sonilo/resources/text_to_sfx.py | 63 ++++++++++++++++ src/sonilo/resources/video_to_sfx.py | 103 +++++++++++++++++++++++++++ tests/test_sfx.py | 82 ++++++++++++++++++++- 5 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 src/sonilo/resources/text_to_sfx.py create mode 100644 src/sonilo/resources/video_to_sfx.py diff --git a/src/sonilo/_async_client.py b/src/sonilo/_async_client.py index 50aec1d..8612e43 100644 --- a/src/sonilo/_async_client.py +++ b/src/sonilo/_async_client.py @@ -10,7 +10,9 @@ from sonilo.resources.account import AsyncAccount from sonilo.resources.tasks import AsyncTasks from sonilo.resources.text_to_music import AsyncTextToMusic +from sonilo.resources.text_to_sfx import AsyncTextToSfx from sonilo.resources.video_to_music import AsyncVideoToMusic +from sonilo.resources.video_to_sfx import AsyncVideoToSfx from sonilo.types import StreamEvent @@ -31,6 +33,8 @@ def __init__( ) self.text_to_music = AsyncTextToMusic(self) self.video_to_music = AsyncVideoToMusic(self) + self.text_to_sfx = AsyncTextToSfx(self) + self.video_to_sfx = AsyncVideoToSfx(self) self.account = AsyncAccount(self) self.tasks = AsyncTasks(self) @@ -51,6 +55,23 @@ async def _get_json(self, path: str, params: Optional[Dict[str, Any]] = None) -> raise error_from_response(response) return response.json() + async def _post_json( + self, + path: str, + *, + data: Dict[str, str], + files: Optional[Dict[str, tuple]] = None, + close_after: Any = None, + ) -> Any: + try: + response = await self._http.post(path, data=data, files=files) + finally: + if close_after is not None: + close_after.close() + if response.status_code >= 400: + raise error_from_response(response) + return response.json() + async def _stream_events( self, path: str, diff --git a/src/sonilo/_client.py b/src/sonilo/_client.py index 70daa80..c7a3607 100644 --- a/src/sonilo/_client.py +++ b/src/sonilo/_client.py @@ -11,7 +11,9 @@ from sonilo.resources.account import Account from sonilo.resources.tasks import Tasks from sonilo.resources.text_to_music import TextToMusic +from sonilo.resources.text_to_sfx import TextToSfx from sonilo.resources.video_to_music import VideoToMusic +from sonilo.resources.video_to_sfx import VideoToSfx from sonilo.types import StreamEvent DEFAULT_BASE_URL = "https://api.sonilo.com" @@ -52,6 +54,8 @@ def __init__( ) self.text_to_music = TextToMusic(self) self.video_to_music = VideoToMusic(self) + self.text_to_sfx = TextToSfx(self) + self.video_to_sfx = VideoToSfx(self) self.account = Account(self) self.tasks = Tasks(self) @@ -72,6 +76,23 @@ def _get_json(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any: raise error_from_response(response) return response.json() + def _post_json( + self, + path: str, + *, + data: Dict[str, str], + files: Optional[Dict[str, tuple]] = None, + close_after: Any = None, + ) -> Any: + try: + response = self._http.post(path, data=data, files=files) + finally: + if close_after is not None: + close_after.close() + if response.status_code >= 400: + raise error_from_response(response) + return response.json() + def _stream_events( self, path: str, diff --git a/src/sonilo/resources/text_to_sfx.py b/src/sonilo/resources/text_to_sfx.py new file mode 100644 index 0000000..2717a24 --- /dev/null +++ b/src/sonilo/resources/text_to_sfx.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from sonilo._requests import build_sfx_t2s_data +from sonilo.resources.tasks import DEFAULT_POLL_INTERVAL, DEFAULT_WAIT_TIMEOUT, parse_sfx_task +from sonilo.types import SfxResult, SfxTask + +if TYPE_CHECKING: + from sonilo._async_client import AsyncSonilo + from sonilo._client import Sonilo + +PATH = "/v1/text-to-sfx" + + +class TextToSfx: + def __init__(self, client: "Sonilo") -> None: + self._client = client + + def submit( + self, *, prompt: str, duration: int, audio_format: Optional[str] = None + ) -> SfxTask: + data = build_sfx_t2s_data(prompt, duration, audio_format) + return parse_sfx_task(self._client._post_json(PATH, data=data)) + + def generate( + self, + *, + prompt: str, + duration: int, + audio_format: Optional[str] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + ) -> SfxResult: + task = self.submit(prompt=prompt, duration=duration, audio_format=audio_format) + return self._client.tasks.wait( + task.task_id, poll_interval=poll_interval, timeout=timeout + ) + + +class AsyncTextToSfx: + def __init__(self, client: "AsyncSonilo") -> None: + self._client = client + + async def submit( + self, *, prompt: str, duration: int, audio_format: Optional[str] = None + ) -> SfxTask: + data = build_sfx_t2s_data(prompt, duration, audio_format) + return parse_sfx_task(await self._client._post_json(PATH, data=data)) + + async def generate( + self, + *, + prompt: str, + duration: int, + audio_format: Optional[str] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + ) -> SfxResult: + task = await self.submit(prompt=prompt, duration=duration, audio_format=audio_format) + return await self._client.tasks.wait( + task.task_id, poll_interval=poll_interval, timeout=timeout + ) diff --git a/src/sonilo/resources/video_to_sfx.py b/src/sonilo/resources/video_to_sfx.py new file mode 100644 index 0000000..16dfe5d --- /dev/null +++ b/src/sonilo/resources/video_to_sfx.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, List, Optional + +from sonilo._requests import build_sfx_v2s_parts +from sonilo.resources.tasks import DEFAULT_POLL_INTERVAL, DEFAULT_WAIT_TIMEOUT, parse_sfx_task +from sonilo.types import SfxResult, SfxSegment, SfxTask + +if TYPE_CHECKING: + from sonilo._async_client import AsyncSonilo + from sonilo._client import Sonilo + +PATH = "/v1/video-to-sfx" + + +class VideoToSfx: + def __init__(self, client: "Sonilo") -> None: + self._client = client + + def submit( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + prompt: Optional[str] = None, + segments: Optional[List[SfxSegment]] = None, + audio_format: Optional[str] = None, + ) -> SfxTask: + data, files, opened = build_sfx_v2s_parts( + video, video_url, prompt, segments, audio_format + ) + close_after = files["video"][1] if files is not None and opened else None + return parse_sfx_task( + self._client._post_json(PATH, data=data, files=files, close_after=close_after) + ) + + def generate( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + prompt: Optional[str] = None, + segments: Optional[List[SfxSegment]] = None, + audio_format: Optional[str] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + ) -> SfxResult: + task = self.submit( + video=video, + video_url=video_url, + prompt=prompt, + segments=segments, + audio_format=audio_format, + ) + return self._client.tasks.wait( + task.task_id, poll_interval=poll_interval, timeout=timeout + ) + + +class AsyncVideoToSfx: + def __init__(self, client: "AsyncSonilo") -> None: + self._client = client + + async def submit( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + prompt: Optional[str] = None, + segments: Optional[List[SfxSegment]] = None, + audio_format: Optional[str] = None, + ) -> SfxTask: + data, files, opened = build_sfx_v2s_parts( + video, video_url, prompt, segments, audio_format + ) + close_after = files["video"][1] if files is not None and opened else None + return parse_sfx_task( + await self._client._post_json( + PATH, data=data, files=files, close_after=close_after + ) + ) + + async def generate( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + prompt: Optional[str] = None, + segments: Optional[List[SfxSegment]] = None, + audio_format: Optional[str] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + ) -> SfxResult: + task = await self.submit( + video=video, + video_url=video_url, + prompt=prompt, + segments=segments, + audio_format=audio_format, + ) + return await self._client.tasks.wait( + task.task_id, poll_interval=poll_interval, timeout=timeout + ) diff --git a/tests/test_sfx.py b/tests/test_sfx.py index b66d015..f6d9d96 100644 --- a/tests/test_sfx.py +++ b/tests/test_sfx.py @@ -7,7 +7,7 @@ import sonilo.resources.tasks as tasks_module # restored in Task 3 from sonilo import Sonilo from sonilo._requests import build_sfx_t2s_data, build_sfx_v2s_parts -from sonilo.errors import SoniloError, TaskFailedError, TaskTimeoutError +from sonilo.errors import PaymentRequiredError, SoniloError, TaskFailedError, TaskTimeoutError from sonilo.types import SfxMedia BASE = "https://api.sonilo.com" @@ -146,3 +146,83 @@ def advance(seconds): with pytest.raises(TaskTimeoutError) as exc_info: client.tasks.wait("t1", poll_interval=1.0, timeout=3.0) assert exc_info.value.task_id == "t1" + + +@respx.mock +def test_text_to_sfx_submit_posts_form(): + route = respx.post(f"{BASE}/v1/text-to-sfx").mock( + return_value=httpx.Response(202, json={"task_id": "t1", "status": "processing"}) + ) + with make_client() as client: + task = client.text_to_sfx.submit(prompt="glass breaking", duration=5, audio_format="wav") + assert task.task_id == "t1" + assert task.status == "processing" + body = route.calls.last.request.content.decode() + assert "glass" in body + assert "wav" in body + + +@respx.mock +def test_text_to_sfx_submit_http_error_maps(): + respx.post(f"{BASE}/v1/text-to-sfx").mock( + return_value=httpx.Response(402, json={"detail": "Insufficient balance"}) + ) + with make_client() as client: + with pytest.raises(PaymentRequiredError): + client.text_to_sfx.submit(prompt="p", duration=5) + + +@respx.mock +def test_video_to_sfx_submit_uploads_multipart(tmp_path): + path = tmp_path / "clip.mp4" + path.write_bytes(b"fakevideo") + route = respx.post(f"{BASE}/v1/video-to-sfx").mock( + return_value=httpx.Response(202, json={"task_id": "t2", "status": "processing"}) + ) + with make_client() as client: + task = client.video_to_sfx.submit( + video=str(path), segments=[{"start": 0, "end": 1.0, "prompt": "pop"}] + ) + assert task.task_id == "t2" + body = route.calls.last.request.content + assert b"clip.mp4" in body + assert b"fakevideo" in body + assert b"segments" in body + + +@respx.mock +def test_video_to_sfx_submit_url_variant(): + route = respx.post(f"{BASE}/v1/video-to-sfx").mock( + return_value=httpx.Response(202, json={"task_id": "t2", "status": "processing"}) + ) + with make_client() as client: + client.video_to_sfx.submit(video_url="https://e.com/v.mp4", audio_format="mp3") + body = route.calls.last.request.content + assert b"video_url" in body + assert b"mp3" in body + + +def test_video_to_sfx_xor_validation(): + with make_client() as client: + with pytest.raises(SoniloError): + client.video_to_sfx.submit(video=b"x", video_url="https://e.com/v.mp4") + with pytest.raises(SoniloError): + client.video_to_sfx.submit() + + +@respx.mock +def test_text_to_sfx_generate_submits_and_waits(monkeypatch): + monkeypatch.setattr(tasks_module, "_sleep", lambda s: None) + respx.post(f"{BASE}/v1/text-to-sfx").mock( + return_value=httpx.Response(202, json={"task_id": "t1", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/t1").mock( + side_effect=[ + httpx.Response(200, json={"task_id": "t1", "status": "processing"}), + httpx.Response(200, json=SUCCEEDED), + ] + ) + with make_client() as client: + result = client.text_to_sfx.generate(prompt="glass", duration=5) + assert result.status == "succeeded" + assert result.audio is not None From 9155da69f8c01cb70c806a4403ffff593a44b4a3 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 12 Jul 2026 01:35:38 -0700 Subject: [PATCH 05/18] test: tighten text-to-sfx prompt assertion --- tests/test_sfx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_sfx.py b/tests/test_sfx.py index f6d9d96..9a0ab57 100644 --- a/tests/test_sfx.py +++ b/tests/test_sfx.py @@ -158,7 +158,7 @@ def test_text_to_sfx_submit_posts_form(): assert task.task_id == "t1" assert task.status == "processing" body = route.calls.last.request.content.decode() - assert "glass" in body + assert "glass+breaking" in body assert "wav" in body From 957f8427d70785c812da6d26c1f4d6a0be8da5bf Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 12 Jul 2026 01:37:13 -0700 Subject: [PATCH 06/18] test: async client coverage for SFX endpoints --- tests/test_sfx_async.py | 82 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 tests/test_sfx_async.py diff --git a/tests/test_sfx_async.py b/tests/test_sfx_async.py new file mode 100644 index 0000000..a18cfeb --- /dev/null +++ b/tests/test_sfx_async.py @@ -0,0 +1,82 @@ +import httpx +import pytest +import respx + +import sonilo.resources.tasks as tasks_module +from sonilo import AsyncSonilo +from sonilo.errors import TaskFailedError + +BASE = "https://api.sonilo.com" + +SUCCEEDED = { + "task_id": "t1", + "type": "text_to_sfx", + "status": "succeeded", + "audio": { + "url": "https://r2.example.com/audio.m4a", + "content_type": "audio/mp4", + "file_size": 123, + }, +} + + +def make_client() -> AsyncSonilo: + return AsyncSonilo(api_key="sk_test_123") + + +@respx.mock +async def test_async_generate_polls_to_success(monkeypatch): + async def no_sleep(seconds): + return None + + monkeypatch.setattr(tasks_module, "_async_sleep", no_sleep) + respx.post(f"{BASE}/v1/text-to-sfx").mock( + return_value=httpx.Response(202, json={"task_id": "t1", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/t1").mock( + side_effect=[ + httpx.Response(200, json={"task_id": "t1", "status": "processing"}), + httpx.Response(200, json=SUCCEEDED), + ] + ) + async with make_client() as client: + result = await client.text_to_sfx.generate(prompt="glass", duration=5) + assert result.status == "succeeded" + + +@respx.mock +async def test_async_wait_raises_task_failed(monkeypatch): + async def no_sleep(seconds): + return None + + monkeypatch.setattr(tasks_module, "_async_sleep", no_sleep) + respx.get(f"{BASE}/v1/tasks/t1").mock( + return_value=httpx.Response( + 200, + json={ + "task_id": "t1", + "status": "failed", + "error": {"code": "GENERATION_FAILED", "message": "boom"}, + "refunded": False, + }, + ) + ) + async with make_client() as client: + with pytest.raises(TaskFailedError) as exc_info: + await client.tasks.wait("t1") + assert exc_info.value.refunded is False + + +@respx.mock +async def test_async_video_to_sfx_submit_url(): + route = respx.post(f"{BASE}/v1/video-to-sfx").mock( + return_value=httpx.Response(202, json={"task_id": "t2", "status": "processing"}) + ) + async with make_client() as client: + task = await client.video_to_sfx.submit( + video_url="https://e.com/v.mp4", audio_format="mp3" + ) + assert task.task_id == "t2" + body = route.calls.last.request.content + assert b"video_url" in body + assert b"mp3" in body From bea6b2393e3d79ef52fe99f1c448bef2ab592073 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 12 Jul 2026 01:40:09 -0700 Subject: [PATCH 07/18] docs: SFX usage, example, bump to 0.2.0 --- README.md | 32 ++++++++++++++++++++++++++++++++ examples/sfx.py | 12 ++++++++++++ pyproject.toml | 2 +- src/sonilo/_version.py | 2 +- 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 examples/sfx.py diff --git a/README.md b/README.md index 9586776..7271217 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,38 @@ client.text_to_music.generate( ) ``` +## Sound effects (async tasks) + +SFX endpoints are asynchronous: submitting returns a `task_id`, and the result +is fetched by polling. `generate()` wraps submit + poll: + +```python +from sonilo import Sonilo + +with Sonilo() as client: + result = client.text_to_sfx.generate(prompt="glass shattering", duration=5) + result.save("sfx.m4a") +``` + +Or control polling yourself: + +```python +task = client.video_to_sfx.submit( + video="clip.mp4", + segments=[{"start": 0, "end": 2.5, "prompt": "footsteps on gravel"}], + audio_format="wav", +) +result = client.tasks.wait(task.task_id, poll_interval=2.0, timeout=600.0) +result.save("audio.wav") +result.save("with_audio.mp4", which="video") # video-to-sfx also returns the muxed video +``` + +`tasks.get(task_id)` fetches state once and never raises on a failed task; +`tasks.wait()` / `generate()` raise `TaskFailedError` (with `.code`, +`.refunded`) on failure and `TaskTimeoutError` if the deadline passes — the +task keeps running server-side and can still be polled afterwards. Result URLs +are presigned and expire; download promptly or re-fetch via `tasks.get`. + ## Account ```python diff --git a/examples/sfx.py b/examples/sfx.py new file mode 100644 index 0000000..ab808c7 --- /dev/null +++ b/examples/sfx.py @@ -0,0 +1,12 @@ +"""Generate a sound effect from text and save it locally. + +Usage: SONILO_API_KEY=sk_... python examples/sfx.py +""" +from sonilo import Sonilo + +with Sonilo() as client: + result = client.text_to_sfx.generate( + prompt="glass shattering on a stone floor", duration=5 + ) + path = result.save("sfx.m4a") + print(f"Saved {path}") diff --git a/pyproject.toml b/pyproject.toml index 4669c6e..d36b5de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo" -version = "0.1.0" +version = "0.2.0" description = "Official Python client for the Sonilo API" readme = "README.md" license = "MIT" diff --git a/src/sonilo/_version.py b/src/sonilo/_version.py index 3dc1f76..d3ec452 100644 --- a/src/sonilo/_version.py +++ b/src/sonilo/_version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.2.0" From 37ba3539e4e6bc3d053015188c73dde7d1bbb159 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 12 Jul 2026 08:09:17 -0700 Subject: [PATCH 08/18] fix: URL-encode task_id in tasks.get Percent-encode task_id before interpolating it into the /v1/tasks/{id} path so ids containing path separators or other reserved characters can't alter the request path. --- src/sonilo/resources/tasks.py | 9 +++++++-- tests/test_sfx.py | 11 +++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/sonilo/resources/tasks.py b/src/sonilo/resources/tasks.py index 01d58da..b0666c1 100644 --- a/src/sonilo/resources/tasks.py +++ b/src/sonilo/resources/tasks.py @@ -3,6 +3,7 @@ import asyncio import time from typing import TYPE_CHECKING, Any, Dict, Optional +from urllib.parse import quote from sonilo.errors import TaskFailedError, TaskTimeoutError from sonilo.types import SfxMedia, SfxResult, SfxTask @@ -75,7 +76,9 @@ def __init__(self, client: "Sonilo") -> None: def get(self, task_id: str) -> SfxResult: """Fetch current task state. Never raises on a failed status.""" - return parse_sfx_result(self._client._get_json(f"/v1/tasks/{task_id}")) + return parse_sfx_result( + self._client._get_json(f"/v1/tasks/{quote(task_id, safe='')}") + ) def wait( self, @@ -102,7 +105,9 @@ def __init__(self, client: "AsyncSonilo") -> None: async def get(self, task_id: str) -> SfxResult: """Fetch current task state. Never raises on a failed status.""" - return parse_sfx_result(await self._client._get_json(f"/v1/tasks/{task_id}")) + return parse_sfx_result( + await self._client._get_json(f"/v1/tasks/{quote(task_id, safe='')}") + ) async def wait( self, diff --git a/tests/test_sfx.py b/tests/test_sfx.py index 9a0ab57..895cab8 100644 --- a/tests/test_sfx.py +++ b/tests/test_sfx.py @@ -90,6 +90,17 @@ def test_tasks_get_returns_failed_as_data(): assert result.refunded is True +@respx.mock +def test_tasks_get_url_encodes_task_id(): + route = respx.get(f"{BASE}/v1/tasks/t1%2F..%2Fother").mock( + return_value=httpx.Response(200, json={"task_id": "t1/../other", "status": "processing"}) + ) + with make_client() as client: + result = client.tasks.get("t1/../other") + assert route.called + assert result.status == "processing" + + @respx.mock def test_tasks_wait_polls_until_succeeded(monkeypatch): sleeps = [] From 94f93fb4d692332356ea751f3bb882d9120b9ffd Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 12 Jul 2026 08:09:30 -0700 Subject: [PATCH 09/18] docs: list task errors in README, sfx keywords, test polish Document TaskFailedError/TaskTimeoutError in the README error list, add sfx/sound-effects to package keywords, assert no auth header leaks to presigned download URLs, add an async wait() timeout test, and drop a stale review-cycle comment. --- README.md | 4 +++- pyproject.toml | 2 +- tests/test_sfx.py | 2 +- tests/test_sfx_async.py | 20 +++++++++++++++++++- tests/test_sfx_types.py | 1 + 5 files changed, 25 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7271217..45e039f 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,9 @@ client.account.usage(days=7) All errors extend `SoniloError`: `AuthenticationError` (401), `PaymentRequiredError` (402), `RateLimitError` (429, `.retry_after`), `BadRequestError` (400/413/422, `.detail`), `APIError` (anything else), -and `GenerationError` for failures mid-stream. +`GenerationError` for failures mid-stream, `TaskFailedError` (`.code`, +`.task_id`, `.refunded`) for a failed SFX task, and `TaskTimeoutError` +(`.task_id`) when `tasks.wait()` / `generate()` hits its deadline. ## License diff --git a/pyproject.toml b/pyproject.toml index d36b5de..1ef0d87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ license = "MIT" requires-python = ">=3.9" authors = [{ name = "Sonilo AI" }] dependencies = ["httpx>=0.27"] -keywords = ["sonilo", "music", "generation", "text-to-music", "video-to-music", "ai"] +keywords = ["sonilo", "music", "generation", "text-to-music", "video-to-music", "ai", "sfx", "sound-effects"] [project.urls] Repository = "https://github.com/sonilo-ai/sonilo-python" diff --git a/tests/test_sfx.py b/tests/test_sfx.py index 895cab8..1b29383 100644 --- a/tests/test_sfx.py +++ b/tests/test_sfx.py @@ -4,7 +4,7 @@ import pytest import respx -import sonilo.resources.tasks as tasks_module # restored in Task 3 +import sonilo.resources.tasks as tasks_module from sonilo import Sonilo from sonilo._requests import build_sfx_t2s_data, build_sfx_v2s_parts from sonilo.errors import PaymentRequiredError, SoniloError, TaskFailedError, TaskTimeoutError diff --git a/tests/test_sfx_async.py b/tests/test_sfx_async.py index a18cfeb..671ddd4 100644 --- a/tests/test_sfx_async.py +++ b/tests/test_sfx_async.py @@ -4,7 +4,7 @@ import sonilo.resources.tasks as tasks_module from sonilo import AsyncSonilo -from sonilo.errors import TaskFailedError +from sonilo.errors import TaskFailedError, TaskTimeoutError BASE = "https://api.sonilo.com" @@ -67,6 +67,24 @@ async def no_sleep(seconds): assert exc_info.value.refunded is False +@respx.mock +async def test_async_wait_times_out(monkeypatch): + clock = {"t": 0.0} + monkeypatch.setattr(tasks_module, "_monotonic", lambda: clock["t"]) + + async def advance(seconds): + clock["t"] += seconds + + monkeypatch.setattr(tasks_module, "_async_sleep", advance) + respx.get(f"{BASE}/v1/tasks/t1").mock( + return_value=httpx.Response(200, json={"task_id": "t1", "status": "processing"}) + ) + async with make_client() as client: + with pytest.raises(TaskTimeoutError) as exc_info: + await client.tasks.wait("t1", poll_interval=1.0, timeout=3.0) + assert exc_info.value.task_id == "t1" + + @respx.mock async def test_async_video_to_sfx_submit_url(): route = respx.post(f"{BASE}/v1/video-to-sfx").mock( diff --git a/tests/test_sfx_types.py b/tests/test_sfx_types.py index 0e7a446..6c26d9c 100644 --- a/tests/test_sfx_types.py +++ b/tests/test_sfx_types.py @@ -33,6 +33,7 @@ def test_save_downloads_audio(tmp_path): ) out = make_result().save(tmp_path / "out.m4a") assert out.read_bytes() == b"audiobytes" + assert "authorization" not in respx.calls.last.request.headers @respx.mock From 017a1ed51e4357f90061f0ab6ec2f31c6ac14939 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 12 Jul 2026 08:30:32 -0700 Subject: [PATCH 10/18] fix: harden SFX task parsing, download timeout, and poll-arg validation - _raise_if_failed no longer crashes when the backend sends `error` as a non-dict (e.g. a bare string). - parse_sfx_result/parse_sfx_task raise SoniloError instead of a bare KeyError on malformed/truncated task bodies, so callers catching SoniloError aren't bypassed. - SfxResult.save()/asave() take a timeout kwarg (default 600s) instead of inheriting httpx's ~5s default, matching the SDK client's own timeout. - Tasks.wait/AsyncTasks.wait now both raise SoniloError on a negative poll_interval or timeout, instead of diverging (sync raises ValueError from time.sleep, async busy-polls silently). --- src/sonilo/resources/tasks.py | 41 ++++++++++++++++++--------- src/sonilo/types.py | 24 +++++++++++++--- tests/test_sfx.py | 46 +++++++++++++++++++++++++++++++ tests/test_sfx_async.py | 8 +++++- tests/test_sfx_types.py | 52 +++++++++++++++++++++++++++++++++++ 5 files changed, 153 insertions(+), 18 deletions(-) diff --git a/src/sonilo/resources/tasks.py b/src/sonilo/resources/tasks.py index b0666c1..be84482 100644 --- a/src/sonilo/resources/tasks.py +++ b/src/sonilo/resources/tasks.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any, Dict, Optional from urllib.parse import quote -from sonilo.errors import TaskFailedError, TaskTimeoutError +from sonilo.errors import SoniloError, TaskFailedError, TaskTimeoutError from sonilo.types import SfxMedia, SfxResult, SfxTask if TYPE_CHECKING: @@ -33,26 +33,32 @@ def _media_from(data: Any) -> Optional[SfxMedia]: def parse_sfx_result(body: Dict[str, Any]) -> SfxResult: """Map a GET /v1/tasks/{id} body to SfxResult; unknown fields are ignored.""" - return SfxResult( - task_id=body["task_id"], - status=body["status"], - type=body.get("type"), - audio=_media_from(body.get("audio")), - video=_media_from(body.get("video")), - cost=body.get("cost"), - error=body.get("error"), - refunded=body.get("refunded"), - ) + try: + return SfxResult( + task_id=body["task_id"], + status=body["status"], + type=body.get("type"), + audio=_media_from(body.get("audio")), + video=_media_from(body.get("video")), + cost=body.get("cost"), + error=body.get("error"), + refunded=body.get("refunded"), + ) + except KeyError as e: + raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e def parse_sfx_task(body: Dict[str, Any]) -> SfxTask: """Map a submission ack to SfxTask.""" - return SfxTask(task_id=body["task_id"], status=body.get("status", "processing")) + try: + return SfxTask(task_id=body["task_id"], status=body.get("status", "processing")) + except KeyError as e: + raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e def _raise_if_failed(result: SfxResult) -> None: if result.status == "failed": - error = result.error or {} + error = result.error if isinstance(result.error, dict) else {} message = error.get("message") or "Generation failed" raise TaskFailedError( f"Task {result.task_id} failed: {message}", @@ -62,6 +68,13 @@ def _raise_if_failed(result: SfxResult) -> None: ) +def _validate_wait_args(poll_interval: float, timeout: float) -> None: + if poll_interval < 0: + raise SoniloError(f"poll_interval must be >= 0, got {poll_interval}") + if timeout < 0: + raise SoniloError(f"timeout must be >= 0, got {timeout}") + + def _timeout_error(task_id: str, timeout: float) -> TaskTimeoutError: return TaskTimeoutError( f"Task {task_id} still processing after {timeout:.0f}s; " @@ -88,6 +101,7 @@ def wait( timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> SfxResult: """Poll until the task is terminal; raise on failure or deadline.""" + _validate_wait_args(poll_interval, timeout) deadline = _monotonic() + timeout while True: result = self.get(task_id) @@ -117,6 +131,7 @@ async def wait( timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> SfxResult: """Poll until the task is terminal; raise on failure or deadline.""" + _validate_wait_args(poll_interval, timeout) deadline = _monotonic() + timeout while True: result = await self.get(task_id) diff --git a/src/sonilo/types.py b/src/sonilo/types.py index 07f6678..947671c 100644 --- a/src/sonilo/types.py +++ b/src/sonilo/types.py @@ -7,6 +7,10 @@ from sonilo.errors import SoniloError +# httpx defaults to a ~5s timeout, which is far too short for real media +# downloads. Kept independent of sonilo._client to avoid a circular import. +DOWNLOAD_TIMEOUT = 600.0 + Segment = Dict[str, Any] """{"start": float, "prompt": str, "label": optional str}""" @@ -69,23 +73,35 @@ def _media(self, which: str) -> SfxMedia: raise SoniloError(f"No {which} on this result (status={self.status})") return media - def save(self, path: Union[str, Path], *, which: str = "audio") -> Path: + def save( + self, + path: Union[str, Path], + *, + which: str = "audio", + timeout: float = DOWNLOAD_TIMEOUT, + ) -> Path: """Download the audio (or video) to `path` and return it. The URL is presigned — no API key is sent. """ media = self._media(which) - response = httpx.get(media.url, follow_redirects=True) + response = httpx.get(media.url, follow_redirects=True, timeout=timeout) if response.status_code >= 400: raise SoniloError(f"Download failed: HTTP {response.status_code}") p = Path(path) p.write_bytes(response.content) return p - async def asave(self, path: Union[str, Path], *, which: str = "audio") -> Path: + async def asave( + self, + path: Union[str, Path], + *, + which: str = "audio", + timeout: float = DOWNLOAD_TIMEOUT, + ) -> Path: """Async variant of save().""" media = self._media(which) - async with httpx.AsyncClient(follow_redirects=True) as http: + async with httpx.AsyncClient(follow_redirects=True, timeout=timeout) as http: response = await http.get(media.url) if response.status_code >= 400: raise SoniloError(f"Download failed: HTTP {response.status_code}") diff --git a/tests/test_sfx.py b/tests/test_sfx.py index 1b29383..6e424fd 100644 --- a/tests/test_sfx.py +++ b/tests/test_sfx.py @@ -141,6 +141,52 @@ def test_tasks_wait_raises_task_failed(monkeypatch): assert exc_info.value.refunded is True +@respx.mock +def test_tasks_wait_raises_task_failed_with_non_dict_error(monkeypatch): + monkeypatch.setattr(tasks_module, "_sleep", lambda s: None) + respx.get(f"{BASE}/v1/tasks/t1").mock( + return_value=httpx.Response( + 200, + json={ + "task_id": "t1", + "status": "failed", + "error": "upstream exploded", + }, + ) + ) + with make_client() as client: + with pytest.raises(TaskFailedError) as exc_info: + client.tasks.wait("t1") + assert "Generation failed" in str(exc_info.value) + assert exc_info.value.code is None + + +@respx.mock +def test_tasks_get_missing_task_id_raises_sonilo_error(): + respx.get(f"{BASE}/v1/tasks/t1").mock( + return_value=httpx.Response(200, json={"status": "processing"}) + ) + with make_client() as client: + with pytest.raises(SoniloError): + client.tasks.get("t1") + + +@respx.mock +def test_tasks_get_missing_status_raises_sonilo_error(): + respx.get(f"{BASE}/v1/tasks/t1").mock( + return_value=httpx.Response(200, json={"task_id": "t1"}) + ) + with make_client() as client: + with pytest.raises(SoniloError): + client.tasks.get("t1") + + +def test_tasks_wait_rejects_negative_poll_interval(): + with make_client() as client: + with pytest.raises(SoniloError): + client.tasks.wait("t1", poll_interval=-1) + + @respx.mock def test_tasks_wait_times_out(monkeypatch): clock = {"t": 0.0} diff --git a/tests/test_sfx_async.py b/tests/test_sfx_async.py index 671ddd4..de3ae31 100644 --- a/tests/test_sfx_async.py +++ b/tests/test_sfx_async.py @@ -4,7 +4,7 @@ import sonilo.resources.tasks as tasks_module from sonilo import AsyncSonilo -from sonilo.errors import TaskFailedError, TaskTimeoutError +from sonilo.errors import SoniloError, TaskFailedError, TaskTimeoutError BASE = "https://api.sonilo.com" @@ -85,6 +85,12 @@ async def advance(seconds): assert exc_info.value.task_id == "t1" +async def test_async_wait_rejects_negative_poll_interval(): + async with make_client() as client: + with pytest.raises(SoniloError): + await client.tasks.wait("t1", poll_interval=-1) + + @respx.mock async def test_async_video_to_sfx_submit_url(): route = respx.post(f"{BASE}/v1/video-to-sfx").mock( diff --git a/tests/test_sfx_types.py b/tests/test_sfx_types.py index 6c26d9c..3a9753a 100644 --- a/tests/test_sfx_types.py +++ b/tests/test_sfx_types.py @@ -10,6 +10,7 @@ TaskFailedError, TaskTimeoutError, ) +from sonilo.types import DOWNLOAD_TIMEOUT AUDIO = SfxMedia(url="https://r2.example.com/audio.m4a", content_type="audio/mp4", file_size=10) @@ -57,6 +58,40 @@ def test_save_rejects_unknown_which(tmp_path): make_result().save(tmp_path / "x", which="cover_art") +@respx.mock +def test_save_uses_download_timeout_by_default(tmp_path, monkeypatch): + respx.get("https://r2.example.com/audio.m4a").mock( + return_value=httpx.Response(200, content=b"audiobytes") + ) + captured = {} + real_get = httpx.get + + def spy_get(url, **kwargs): + captured.update(kwargs) + return real_get(url, **kwargs) + + monkeypatch.setattr(httpx, "get", spy_get) + make_result().save(tmp_path / "out.m4a") + assert captured["timeout"] == DOWNLOAD_TIMEOUT + + +@respx.mock +def test_save_passes_through_custom_timeout(tmp_path, monkeypatch): + respx.get("https://r2.example.com/audio.m4a").mock( + return_value=httpx.Response(200, content=b"audiobytes") + ) + captured = {} + real_get = httpx.get + + def spy_get(url, **kwargs): + captured.update(kwargs) + return real_get(url, **kwargs) + + monkeypatch.setattr(httpx, "get", spy_get) + make_result().save(tmp_path / "out.m4a", timeout=1.0) + assert captured["timeout"] == 1.0 + + @respx.mock def test_save_download_http_error_raises(tmp_path): respx.get("https://r2.example.com/audio.m4a").mock( @@ -75,6 +110,23 @@ async def test_asave_downloads_audio(tmp_path): assert out.read_bytes() == b"audiobytes" +@respx.mock +async def test_asave_uses_download_timeout_by_default(tmp_path, monkeypatch): + respx.get("https://r2.example.com/audio.m4a").mock( + return_value=httpx.Response(200, content=b"audiobytes") + ) + captured = {} + real_init = httpx.AsyncClient.__init__ + + def spy_init(self, *args, **kwargs): + captured.update(kwargs) + return real_init(self, *args, **kwargs) + + monkeypatch.setattr(httpx.AsyncClient, "__init__", spy_init) + await make_result().asave(tmp_path / "out.m4a") + assert captured["timeout"] == DOWNLOAD_TIMEOUT + + def test_task_errors_carry_fields(): failed = TaskFailedError("boom", code="GENERATION_FAILED", task_id="t1", refunded=True) assert isinstance(failed, SoniloError) From b113dc715988033c907b5000bf00dd80a594b708 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 12 Jul 2026 08:54:04 -0700 Subject: [PATCH 11/18] fix: fail loudly on malformed audio_chunk and non-object stream lines - _TrackBuilder.add() silently dropped any audio_chunk event whose `data` was missing or not a decodable base64 string (it matched no branch, so it fell through as an unknown event), letting collect_track()/ acollect_track() return a successful Track with empty/truncated audio and no error. Now it raises GenerationError instead. - _parse_line() called `.get()` on the result of json.loads() without checking it was a dict first, so a bare `null` NDJSON line crashed with a raw AttributeError instead of a typed error. It now treats a non-dict line as junk to skip, same as an empty line. --- src/sonilo/_streaming.py | 28 ++++++++++++++++++++----- tests/test_streaming.py | 45 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/sonilo/_streaming.py b/src/sonilo/_streaming.py index 40aff14..e913b30 100644 --- a/src/sonilo/_streaming.py +++ b/src/sonilo/_streaming.py @@ -8,8 +8,14 @@ from sonilo.types import StreamEvent, Track -def _parse_line(line: str) -> StreamEvent: - event = json.loads(line) +def _parse_line(line: str) -> Optional[StreamEvent]: + """Returns `None` for a valid-JSON-but-non-dict line (e.g. a bare `null` + or a number/string), which carries no event `type` and is skipped like + any other junk line rather than crashing on a `.get()` off `None`.""" + parsed = json.loads(line) + if not isinstance(parsed, dict): + return None + event = parsed if event.get("type") == "audio_chunk" and isinstance(event.get("data"), str): event = {**event, "data": base64.b64decode(event["data"])} return event @@ -29,12 +35,16 @@ def feed(self, text: str) -> Iterator[StreamEvent]: return line, self._buf = self._buf[:idx].strip(), self._buf[idx + 1 :] if line: - yield _parse_line(line) + event = _parse_line(line) + if event is not None: + yield event def flush(self) -> Iterator[StreamEvent]: line, self._buf = self._buf.strip(), "" if line: - yield _parse_line(line) + event = _parse_line(line) + if event is not None: + yield event def iter_events(text_chunks: Iterable[str]) -> Iterator[StreamEvent]: @@ -62,7 +72,15 @@ def __init__(self) -> None: def add(self, event: StreamEvent) -> None: event_type = event.get("type") - if event_type == "audio_chunk" and isinstance(event.get("data"), bytes): + if event_type == "audio_chunk": + # A malformed chunk (missing/non-decodable `data`) must not be + # silently dropped: that would hand back a "successful" Track + # with empty or truncated audio and no indication anything went + # wrong. + if not isinstance(event.get("data"), bytes): + raise GenerationError( + "received a malformed audio_chunk event (missing or non-decodable data)" + ) self._chunks.append(event["data"]) elif event_type == "title" and isinstance(event.get("title"), str): self._title = event["title"] diff --git a/tests/test_streaming.py b/tests/test_streaming.py index 15b9db0..7569640 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -63,6 +63,12 @@ def test_unknown_event_passed_through(): assert events == [{"type": "stage_start", "stage": "analyze"}] +def test_bare_null_line_skipped_instead_of_raising_attribute_error(): + text = "null\n" + json.dumps({"type": "complete"}) + "\n" + events = list(iter_events([text])) + assert events == [{"type": "complete"}] + + async def test_aiter_events_matches_sync(): events = [e async for e in aiter_events(as_async_iter(chunked(LINES, 3)))] assert [e["type"] for e in events] == ["title", "audio_chunk", "audio_chunk", "complete"] @@ -164,3 +170,42 @@ def test_collect_track_raises_on_missing_complete(): def test_collect_track_raises_on_empty_stream(): with pytest.raises(GenerationError): collect_track(iter_events([])) + + +def test_collect_track_raises_on_audio_chunk_with_missing_data(): + text = ( + json.dumps({"type": "title", "title": "Skyline"}) + + "\n" + + json.dumps({"type": "audio_chunk"}) + + "\n" + + json.dumps({"type": "complete"}) + + "\n" + ) + with pytest.raises(GenerationError): + collect_track(iter_events([text])) + + +def test_collect_track_raises_on_audio_chunk_with_non_string_data(): + text = ( + json.dumps({"type": "title", "title": "Skyline"}) + + "\n" + + json.dumps({"type": "audio_chunk", "data": 12345}) + + "\n" + + json.dumps({"type": "complete"}) + + "\n" + ) + with pytest.raises(GenerationError): + collect_track(iter_events([text])) + + +async def test_acollect_track_raises_on_audio_chunk_with_missing_data(): + text = ( + json.dumps({"type": "title", "title": "Skyline"}) + + "\n" + + json.dumps({"type": "audio_chunk"}) + + "\n" + + json.dumps({"type": "complete"}) + + "\n" + ) + with pytest.raises(GenerationError): + await acollect_track(aiter_events(as_async_iter([text]))) From fbacdb64a29d81b3a9f3a820b24b96bf45e7d900 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 12 Jul 2026 09:04:20 -0700 Subject: [PATCH 12/18] fix: raise GenerationError on undecodable audio_chunk data base64.b64decode() in _parse_line() was raising a raw binascii.Error for malformed audio_chunk data before the event ever reached the collector, so the existing malformed-chunk check in _TrackBuilder.add never saw it and the error escaped stream()/generate() untyped, breaking the "all errors extend SoniloError" contract. --- src/sonilo/_streaming.py | 11 +++++++++- tests/test_streaming.py | 43 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/sonilo/_streaming.py b/src/sonilo/_streaming.py index e913b30..6893883 100644 --- a/src/sonilo/_streaming.py +++ b/src/sonilo/_streaming.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import binascii import json from typing import AsyncIterable, AsyncIterator, Dict, Iterable, Iterator, List, Optional @@ -17,7 +18,15 @@ def _parse_line(line: str) -> Optional[StreamEvent]: return None event = parsed if event.get("type") == "audio_chunk" and isinstance(event.get("data"), str): - event = {**event, "data": base64.b64decode(event["data"])} + try: + event = {**event, "data": base64.b64decode(event["data"])} + except binascii.Error: + # Don't raise here: this must reach _TrackBuilder.add, whose + # malformed-chunk check turns undecodable data into a typed + # GenerationError. Raising in place would let a raw + # binascii.Error escape stream()/generate(), breaking the SDK's + # "all errors extend SoniloError" contract. + pass return event diff --git a/tests/test_streaming.py b/tests/test_streaming.py index 7569640..c54dfd5 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -209,3 +209,46 @@ async def test_acollect_track_raises_on_audio_chunk_with_missing_data(): ) with pytest.raises(GenerationError): await acollect_track(aiter_events(as_async_iter([text]))) + + +def test_audio_chunk_with_undecodable_base64_data_passes_through_undecoded(): + text = ( + json.dumps({"type": "title", "title": "Skyline"}) + + "\n" + + json.dumps({"type": "audio_chunk", "data": "not-valid-base64!!!"}) + + "\n" + + json.dumps({"type": "complete"}) + + "\n" + ) + events = list(iter_events([text])) + audio_chunk = next(e for e in events if e["type"] == "audio_chunk") + assert audio_chunk["data"] == "not-valid-base64!!!" + + +def test_collect_track_raises_generation_error_on_undecodable_audio_chunk_data(): + text = ( + json.dumps({"type": "title", "title": "Skyline"}) + + "\n" + + json.dumps({"type": "audio_chunk", "data": "not-valid-base64!!!"}) + + "\n" + + json.dumps({"type": "complete"}) + + "\n" + ) + with pytest.raises(GenerationError): + collect_track(iter_events([text])) + + +def test_audio_chunk_empty_string_data_still_decodes_to_zero_length_bytes(): + text = ( + json.dumps({"type": "title", "title": "Skyline"}) + + "\n" + + json.dumps({"type": "audio_chunk", "data": ""}) + + "\n" + + json.dumps({"type": "complete"}) + + "\n" + ) + events = list(iter_events([text])) + audio_chunk = next(e for e in events if e["type"] == "audio_chunk") + assert audio_chunk["data"] == b"" + track = collect_track(iter_events([text])) + assert track.audio == b"" From 69ab41a0a0831e3cfb3ad0751d68b6b4e10c93b1 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 12 Jul 2026 09:14:53 -0700 Subject: [PATCH 13/18] fix: decode audio_chunk base64 strictly to prevent silent corruption --- src/sonilo/_streaming.py | 17 +++++++++--- tests/test_streaming.py | 56 +++++++++++++++++++++++++++++++++++++++ tests/test_sync_client.py | 18 +++++++++++++ 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/src/sonilo/_streaming.py b/src/sonilo/_streaming.py index 6893883..3eb904f 100644 --- a/src/sonilo/_streaming.py +++ b/src/sonilo/_streaming.py @@ -3,6 +3,7 @@ import base64 import binascii import json +import re from typing import AsyncIterable, AsyncIterator, Dict, Iterable, Iterator, List, Optional from sonilo.errors import GenerationError @@ -19,13 +20,21 @@ def _parse_line(line: str) -> Optional[StreamEvent]: event = parsed if event.get("type") == "audio_chunk" and isinstance(event.get("data"), str): try: - event = {**event, "data": base64.b64decode(event["data"])} - except binascii.Error: + # validate=True is required: the lenient (default) decoder + # silently discards any character outside the base64 alphabet + # and re-aligns the remaining quartets instead of raising, so a + # corrupted chunk whose padding still happens to work out would + # decode to fewer, wrong bytes with no error at all. Whitespace + # is stripped first so legitimately-whitespace-containing + # base64 (e.g. wrapped/pretty-printed payloads) still decodes. + decoded = base64.b64decode(re.sub(r"\s", "", event["data"]), validate=True) + event = {**event, "data": decoded} + except (binascii.Error, ValueError): # Don't raise here: this must reach _TrackBuilder.add, whose # malformed-chunk check turns undecodable data into a typed # GenerationError. Raising in place would let a raw - # binascii.Error escape stream()/generate(), breaking the SDK's - # "all errors extend SoniloError" contract. + # binascii.Error/ValueError escape stream()/generate(), breaking + # the SDK's "all errors extend SoniloError" contract. pass return event diff --git a/tests/test_streaming.py b/tests/test_streaming.py index c54dfd5..fba358b 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -238,6 +238,62 @@ def test_collect_track_raises_generation_error_on_undecodable_audio_chunk_data() collect_track(iter_events([text])) +def test_audio_chunk_whitespace_containing_base64_still_decodes(): + payload = "SGVs bG8=" + text = ( + json.dumps({"type": "title", "title": "Skyline"}) + + "\n" + + json.dumps({"type": "audio_chunk", "data": payload}) + + "\n" + + json.dumps({"type": "complete"}) + + "\n" + ) + events = list(iter_events([text])) + audio_chunk = next(e for e in events if e["type"] == "audio_chunk") + assert audio_chunk["data"] == b"Hello" + + text_tabs_newline = ( + json.dumps({"type": "audio_chunk", "data": "SGVs\tbG8=\n"}) + + "\n" + + json.dumps({"type": "complete"}) + + "\n" + ) + track = collect_track(iter_events([text_tabs_newline])) + assert track.audio == b"Hello" + + +def test_collect_track_raises_on_padding_preserving_corrupted_audio_chunk(): + """The old lenient decoder silently discarded the invalid `!!!!` + characters and re-aligned the remaining quartets, producing fewer, + wrong bytes with no error -- a real 'This is a longer...' payload + corrupted this way decoded to a *plausible-looking but wrong* string + instead of raising. Strict (validate=True) decoding must reject it.""" + good = base64.b64encode( + b"This is a longer audio payload for testing, twenty bytes" + ).decode() + corrupted = good[:10] + "!!!!" + good[14:] + text = ( + json.dumps({"type": "audio_chunk", "data": corrupted}) + + "\n" + + json.dumps({"type": "complete"}) + + "\n" + ) + with pytest.raises(GenerationError): + collect_track(iter_events([text])) + + +@pytest.mark.parametrize("data", ["SGVsbG!8=", "U29tZSBhdWRpbyBkYXRh_-"]) +def test_collect_track_raises_on_invalid_alphabet_characters(data): + text = ( + json.dumps({"type": "audio_chunk", "data": data}) + + "\n" + + json.dumps({"type": "complete"}) + + "\n" + ) + with pytest.raises(GenerationError): + collect_track(iter_events([text])) + + def test_audio_chunk_empty_string_data_still_decodes_to_zero_length_bytes(): text = ( json.dumps({"type": "title", "title": "Skyline"}) diff --git a/tests/test_sync_client.py b/tests/test_sync_client.py index 490552b..a403e4a 100644 --- a/tests/test_sync_client.py +++ b/tests/test_sync_client.py @@ -84,6 +84,24 @@ def test_generate_raises_generation_error_on_error_event(): client.text_to_music.generate(prompt="p", duration=10) +@respx.mock +def test_generate_raises_generation_error_on_padding_preserving_corrupted_audio_chunk(): + """A corrupted chunk whose padding still lines up (e.g. 4 characters + clobbered to `!!!!`) must not silently decode to fewer, wrong bytes: it + must surface as a typed GenerationError, not a successful Track.""" + good = b64(b"This is a longer audio payload for testing, twenty bytes") + corrupted = good[:10] + "!!!!" + good[14:] + respx.post(f"{BASE}/v1/text-to-music").mock( + return_value=httpx.Response( + 200, content=ndjson({"type": "audio_chunk", "data": corrupted}, {"type": "complete"}) + ) + ) + with make_client() as client: + with pytest.raises(GenerationError) as excinfo: + client.text_to_music.generate(prompt="p", duration=10) + assert type(excinfo.value) is GenerationError + + @respx.mock def test_http_error_maps_to_typed_error(): respx.post(f"{BASE}/v1/text-to-music").mock( From eac2550ad3caf1d44c36325a8582f21a90bd76d7 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 12 Jul 2026 09:19:26 -0700 Subject: [PATCH 14/18] fix: clamp poll sleep to the remaining deadline Tasks.wait/AsyncTasks.wait slept the full poll_interval even when it exceeded the remaining timeout, so TaskTimeoutError fired long after the deadline (e.g. poll_interval=1000, timeout=5 slept ~1000s before raising). Sleep is now clamped to the time remaining until deadline. --- src/sonilo/resources/tasks.py | 10 ++++++---- tests/test_sfx.py | 23 +++++++++++++++++++++++ tests/test_sfx_async.py | 23 +++++++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/sonilo/resources/tasks.py b/src/sonilo/resources/tasks.py index be84482..9007387 100644 --- a/src/sonilo/resources/tasks.py +++ b/src/sonilo/resources/tasks.py @@ -108,9 +108,10 @@ def wait( if result.status == "succeeded": return result _raise_if_failed(result) - if _monotonic() >= deadline: + remaining = deadline - _monotonic() + if remaining <= 0: raise _timeout_error(task_id, timeout) - _sleep(poll_interval) + _sleep(min(poll_interval, remaining)) class AsyncTasks: @@ -138,6 +139,7 @@ async def wait( if result.status == "succeeded": return result _raise_if_failed(result) - if _monotonic() >= deadline: + remaining = deadline - _monotonic() + if remaining <= 0: raise _timeout_error(task_id, timeout) - await _async_sleep(poll_interval) + await _async_sleep(min(poll_interval, remaining)) diff --git a/tests/test_sfx.py b/tests/test_sfx.py index 6e424fd..3d118f3 100644 --- a/tests/test_sfx.py +++ b/tests/test_sfx.py @@ -205,6 +205,29 @@ def advance(seconds): assert exc_info.value.task_id == "t1" +@respx.mock +def test_tasks_wait_clamps_sleep_to_remaining_deadline(monkeypatch): + clock = {"t": 0.0} + monkeypatch.setattr(tasks_module, "_monotonic", lambda: clock["t"]) + sleeps = [] + + def fake_sleep(seconds): + sleeps.append(seconds) + clock["t"] += seconds + + monkeypatch.setattr(tasks_module, "_sleep", fake_sleep) + respx.get(f"{BASE}/v1/tasks/t1").mock( + return_value=httpx.Response(200, json={"task_id": "t1", "status": "processing"}) + ) + with make_client() as client: + with pytest.raises(TaskTimeoutError) as exc_info: + client.tasks.wait("t1", poll_interval=1000.0, timeout=5.0) + assert exc_info.value.task_id == "t1" + # The poll_interval (1000s) is far larger than the timeout (5s); the sleep + # must be clamped to the remaining time, not the full poll_interval. + assert sleeps == [5.0] + + @respx.mock def test_text_to_sfx_submit_posts_form(): route = respx.post(f"{BASE}/v1/text-to-sfx").mock( diff --git a/tests/test_sfx_async.py b/tests/test_sfx_async.py index de3ae31..5aa2e00 100644 --- a/tests/test_sfx_async.py +++ b/tests/test_sfx_async.py @@ -85,6 +85,29 @@ async def advance(seconds): assert exc_info.value.task_id == "t1" +@respx.mock +async def test_async_wait_clamps_sleep_to_remaining_deadline(monkeypatch): + clock = {"t": 0.0} + monkeypatch.setattr(tasks_module, "_monotonic", lambda: clock["t"]) + sleeps = [] + + async def fake_sleep(seconds): + sleeps.append(seconds) + clock["t"] += seconds + + monkeypatch.setattr(tasks_module, "_async_sleep", fake_sleep) + respx.get(f"{BASE}/v1/tasks/t1").mock( + return_value=httpx.Response(200, json={"task_id": "t1", "status": "processing"}) + ) + async with make_client() as client: + with pytest.raises(TaskTimeoutError) as exc_info: + await client.tasks.wait("t1", poll_interval=1000.0, timeout=5.0) + assert exc_info.value.task_id == "t1" + # The poll_interval (1000s) is far larger than the timeout (5s); the sleep + # must be clamped to the remaining time, not the full poll_interval. + assert sleeps == [5.0] + + async def test_async_wait_rejects_negative_poll_interval(): async with make_client() as client: with pytest.raises(SoniloError): From ed8d657b7cba5433274dfcaca25aef30da15e245 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 12 Jul 2026 09:34:51 -0700 Subject: [PATCH 15/18] fix: align audio_chunk base64 decoding with WHATWG forgiving-base64 base64.b64decode(..., validate=True) required correct `=` padding and stripped Unicode \s whitespace, unlike the JS SDK's atob(). This meant a byte-identical audio_chunk payload (e.g. unpadded base64, or base64 containing NBSP/vertical-tab/U+2028) could decode successfully in JS while spuriously raising GenerationError in Python. Add _forgiving_b64decode(), which implements the WHATWG forgiving-base64-decode algorithm atob() uses: strip only ASCII whitespace, drop trailing padding when present, reject remainder-1 lengths and non-alphabet characters, then pad to a multiple of 4 before decoding with validate=True. Cross-checked all 15 payloads (6 decode-success, 9 raise) against Node's atob() in sonilo-js -- outcomes and decoded bytes agree in every case. --- src/sonilo/_streaming.py | 43 ++++++++++++++++---- tests/test_sync_client.py | 83 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 8 deletions(-) diff --git a/src/sonilo/_streaming.py b/src/sonilo/_streaming.py index 3eb904f..72435d9 100644 --- a/src/sonilo/_streaming.py +++ b/src/sonilo/_streaming.py @@ -10,6 +10,34 @@ from sonilo.types import StreamEvent, Track +def _forgiving_b64decode(data: str) -> bytes: + """Decode base64 the way the JS SDK's `atob()` does: WHATWG's + "forgiving-base64 decode" algorithm (https://infra.spec.whatwg.org/#forgiving-base64-decode). + + This differs from `base64.b64decode` in two ways that matter for + cross-SDK parity on the same wire payload: + - Padding is optional. `atob()` does not require `=` padding, so an + upstream re-encoding that omits it must still decode instead of + spuriously failing a generation the JS SDK handles fine. + - Only ASCII whitespace (space, tab, LF, FF, CR) is stripped before + decoding, not all Unicode whitespace (`atob()` throws on NBSP, + vertical tab, U+2028, etc., so we must reject those too, not + silently strip them). + """ + cleaned = re.sub(r"[ \t\n\f\r]", "", data) + if len(cleaned) % 4 == 0 and cleaned.endswith("="): + cleaned = cleaned[:-2] if cleaned.endswith("==") else cleaned[:-1] + if len(cleaned) % 4 == 1: + raise binascii.Error( + "Invalid base64-encoded string: number of data characters cannot be 1 more " + "than a multiple of 4" + ) + if not re.fullmatch(r"[A-Za-z0-9+/]*", cleaned): + raise binascii.Error("Non-base64 digit found") + padded = cleaned + "=" * (-len(cleaned) % 4) + return base64.b64decode(padded, validate=True) + + def _parse_line(line: str) -> Optional[StreamEvent]: """Returns `None` for a valid-JSON-but-non-dict line (e.g. a bare `null` or a number/string), which carries no event `type` and is skipped like @@ -20,14 +48,13 @@ def _parse_line(line: str) -> Optional[StreamEvent]: event = parsed if event.get("type") == "audio_chunk" and isinstance(event.get("data"), str): try: - # validate=True is required: the lenient (default) decoder - # silently discards any character outside the base64 alphabet - # and re-aligns the remaining quartets instead of raising, so a - # corrupted chunk whose padding still happens to work out would - # decode to fewer, wrong bytes with no error at all. Whitespace - # is stripped first so legitimately-whitespace-containing - # base64 (e.g. wrapped/pretty-printed payloads) still decodes. - decoded = base64.b64decode(re.sub(r"\s", "", event["data"]), validate=True) + # Mirror the JS SDK's `atob()` (WHATWG forgiving-base64) exactly, + # via _forgiving_b64decode: unpadded base64 must decode (not + # raise), only ASCII whitespace is stripped (not all Unicode + # whitespace), and an invalid-alphabet or misaligned-length + # payload must still raise so it doesn't silently decode to + # fewer, wrong bytes. + decoded = _forgiving_b64decode(event["data"]) event = {**event, "data": decoded} except (binascii.Error, ValueError): # Don't raise here: this must reach _TrackBuilder.add, whose diff --git a/tests/test_sync_client.py b/tests/test_sync_client.py index a403e4a..a7f0f3d 100644 --- a/tests/test_sync_client.py +++ b/tests/test_sync_client.py @@ -170,3 +170,86 @@ def test_account_endpoints(): assert client.account.services()["rpm_limit"] == 60 assert client.account.usage(days=7) == {"summary": {}, "daily": []} assert usage_route.called + + +# --- audio_chunk base64 decoding parity with the JS SDK's atob() --------- +# +# atob() implements WHATWG "forgiving-base64 decode": padding is optional, +# only ASCII whitespace (space/tab/LF/FF/CR) is stripped before decoding +# (not full Unicode \s), and anything else invalid must raise. These cases +# are driven through the real public API (client.text_to_music.generate(), +# which goes through stream() -> collect_track()) rather than the private +# decode helper, so a regression here is caught the same way a real +# consumer would hit it. + +_GOOD_LONG = b64(b"This is a longer audio payload for testing, twenty bytes") + +DECODES_TO = [ + pytest.param(b64(b"abc"), b"abc", id="valid-padded-base64"), + pytest.param("SGVsbG8", b"Hello", id="unpadded-remainder-3"), + pytest.param("SGVsbA", b"Hell", id="unpadded-remainder-2"), + pytest.param("SGVs bG8=", b"Hello", id="ascii-space-inside"), + pytest.param("SGVs\tbG8=\n", b"Hello", id="ascii-tab-and-trailing-newline"), + pytest.param("", b"", id="empty-string"), +] + +RAISES_GENERATION_ERROR = [ + pytest.param( + _GOOD_LONG[:10] + "!!!!" + _GOOD_LONG[14:], id="padding-preserving-corrupted" + ), + pytest.param("SGVsbG!8=", id="invalid-char-bang"), + pytest.param("U29tZSBhdWRpbyBkYXRh_-", id="url-safe-alphabet-not-accepted"), + pytest.param("!!!!", id="all-invalid-chars"), + pytest.param("not-valid-base64!!!", id="not-valid-base64"), + pytest.param("SGVsbG8h5", id="remainder-1-length"), + pytest.param("SGVs bG8=", id="nbsp-inside"), + pytest.param("SGVs bG8=", id="vertical-tab-inside"), + pytest.param("SGVs
bG8=", id="line-separator-inside"), +] + + +@pytest.mark.parametrize("payload, expected_audio", DECODES_TO) +@respx.mock +def test_generate_decodes_audio_chunk_matching_atob(payload, expected_audio): + respx.post(f"{BASE}/v1/text-to-music").mock( + return_value=httpx.Response( + 200, content=ndjson({"type": "audio_chunk", "data": payload}, {"type": "complete"}) + ) + ) + with make_client() as client: + track = client.text_to_music.generate(prompt="p", duration=10) + assert track.audio == expected_audio + + +@pytest.mark.parametrize("payload", RAISES_GENERATION_ERROR) +@respx.mock +def test_generate_raises_generation_error_for_atob_rejected_payload(payload): + respx.post(f"{BASE}/v1/text-to-music").mock( + return_value=httpx.Response( + 200, content=ndjson({"type": "audio_chunk", "data": payload}, {"type": "complete"}) + ) + ) + with make_client() as client: + with pytest.raises(GenerationError): + client.text_to_music.generate(prompt="p", duration=10) + + +@respx.mock +def test_generate_passes_through_unknown_event_types_untouched(): + """Forward-compatible: an event type the SDK doesn't recognize yet must + not break generate() -- it's ignored, and the rest of the stream is + still processed normally.""" + respx.post(f"{BASE}/v1/text-to-music").mock( + return_value=httpx.Response( + 200, + content=ndjson( + {"type": "stage_start", "stage": "analyze"}, + {"type": "audio_chunk", "data": b64(b"abc")}, + {"type": "future_event_type", "some": "payload"}, + {"type": "complete"}, + ), + ) + ) + with make_client() as client: + track = client.text_to_music.generate(prompt="p", duration=10) + assert track.audio == b"abc" From 655a929017fd9bdaa27fa3548d406a0fe05402bf Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 12 Jul 2026 09:50:21 -0700 Subject: [PATCH 16/18] fix: fall back to default stream error message for any non-string value Python used truthiness, so a non-string truthy `message` (e.g. 42) was stringified verbatim while the JS SDK fell back to the default. Both SDKs now use only a non-empty string message and fall back otherwise. --- src/sonilo/_streaming.py | 9 +++++++-- tests/test_streaming.py | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/sonilo/_streaming.py b/src/sonilo/_streaming.py index 72435d9..b3842b1 100644 --- a/src/sonilo/_streaming.py +++ b/src/sonilo/_streaming.py @@ -132,9 +132,14 @@ def add(self, event: StreamEvent) -> None: elif event_type == "cost": self._cost = {k: v for k, v in event.items() if k != "type"} elif event_type == "error": - message = event.get("message") or "generation failed" + raw_message = event.get("message") + message = ( + raw_message + if isinstance(raw_message, str) and raw_message + else "generation failed" + ) code = event.get("code") - raise GenerationError(str(message), code=code if isinstance(code, str) else None) + raise GenerationError(message, code=code if isinstance(code, str) else None) elif event_type == "complete": self._complete = True # unknown event types: ignored diff --git a/tests/test_streaming.py b/tests/test_streaming.py index fba358b..321b435 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -120,6 +120,28 @@ def test_collect_track_raises_generation_error_on_error_event(): assert "upstream died" in str(excinfo.value) +@pytest.mark.parametrize( + "message", + [ + "", # empty string + None, + 42, # non-string, truthy + [1, 2, 3], + True, + ], +) +def test_collect_track_error_message_falls_back_to_default(message): + """Only a non-empty string message is used verbatim; everything else falls + back to the default, matching the JS SDK.""" + event = {"type": "error", "code": "X"} + if message is not None: + event["message"] = message + with pytest.raises(GenerationError) as excinfo: + collect_track(iter_events([json.dumps(event) + "\n"])) + assert str(excinfo.value) == "generation failed" + assert excinfo.value.code == "X" + + async def test_acollect_track_matches_sync(): track = await acollect_track(aiter_events(as_async_iter(chunked(LINES, 5)))) assert track.audio == b"abcdef" From de112b16127ad4e184cab9906b685c608c25ff5d Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 12 Jul 2026 10:24:39 -0700 Subject: [PATCH 17/18] fix: parse the API's {code, message} error envelope, not FastAPI's detail Routes under /v1/* return errors as {"code": "...", "message": "..."} rather than FastAPI's default {"detail": "..."}, so every APIError message was silently degrading to the HTTP reason phrase. Build the message from `message` (falling back to legacy `detail`), and expose `.code` and `.errors` on APIError so callers can branch on the API's typed error code and read 422 validation details. --- README.md | 5 +++ src/sonilo/errors.py | 26 ++++++++++++++-- tests/test_errors.py | 73 +++++++++++++++++++++++++++++++++++++++----- 3 files changed, 94 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 45e039f..010ea42 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,11 @@ All errors extend `SoniloError`: `AuthenticationError` (401), `.task_id`, `.refunded`) for a failed SFX task, and `TaskTimeoutError` (`.task_id`) when `tasks.wait()` / `generate()` hits its deadline. +Every `APIError` also carries `.status_code`, `.body` (the parsed response), +`.code` (the API's error code, e.g. `"rate_limit_exceeded"`), and `.errors` +(the validation detail list on a 422), in addition to any subclass-specific +attributes above. + ## License MIT diff --git a/src/sonilo/errors.py b/src/sonilo/errors.py index 4b2b32f..1125b09 100644 --- a/src/sonilo/errors.py +++ b/src/sonilo/errors.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any, List, Optional import httpx @@ -14,6 +14,15 @@ def __init__(self, message: str, status_code: int, body: Any = None) -> None: super().__init__(message) self.status_code = status_code self.body = body + self.code: Optional[str] = None + self.errors: Optional[List[Any]] = None + if isinstance(body, dict): + code = body.get("code") + if isinstance(code, str): + self.code = code + errors = body.get("errors") + if isinstance(errors, list): + self.errors = errors class AuthenticationError(APIError): @@ -28,6 +37,9 @@ class BadRequestError(APIError): @property def detail(self) -> Optional[str]: if isinstance(self.body, dict): + message = self.body.get("message") + if isinstance(message, str) and message: + return message detail = self.body.get("detail") if isinstance(detail, str): return detail @@ -90,8 +102,16 @@ def error_from_response(response: httpx.Response) -> APIError: body: Any = response.json() except ValueError: body = response.text - detail = body.get("detail") if isinstance(body, dict) else None - message = f"HTTP {response.status_code}: {detail or response.reason_phrase or 'request failed'}" + reason = None + if isinstance(body, dict): + api_message = body.get("message") + if isinstance(api_message, str) and api_message: + reason = api_message + else: + detail = body.get("detail") + if detail: + reason = detail if isinstance(detail, str) else str(detail) + message = f"HTTP {response.status_code}: {reason or response.reason_phrase or 'request failed'}" status = response.status_code if status == 401: diff --git a/tests/test_errors.py b/tests/test_errors.py index 9db8bcc..2c1667a 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -19,36 +19,95 @@ def make_response(status_code, json_body=None, text=None, headers=None): return httpx.Response(status_code, text=text or "", headers=headers) +def test_400_maps_to_bad_request_with_code_and_message_envelope(): + err = error_from_response( + make_response( + 400, + {"code": "invalid_request", "message": "audio_format must be one of wav, mp3, aac, flac"}, + ) + ) + assert isinstance(err, BadRequestError) + assert "audio_format must be one of" in str(err) + assert err.code == "invalid_request" + assert err.detail == "audio_format must be one of wav, mp3, aac, flac" + + def test_401_maps_to_authentication_error(): - err = error_from_response(make_response(401, {"detail": "Invalid API key"})) + err = error_from_response( + make_response(401, {"code": "unauthorized", "message": "Invalid API key"}) + ) assert isinstance(err, AuthenticationError) assert err.status_code == 401 assert "Invalid API key" in str(err) + assert err.code == "unauthorized" def test_402_maps_to_payment_required(): - err = error_from_response(make_response(402, {"detail": "Insufficient balance"})) + err = error_from_response( + make_response(402, {"code": "payment_required", "message": "Insufficient balance"}) + ) assert isinstance(err, PaymentRequiredError) + assert "Insufficient balance" in str(err) -def test_429_maps_to_rate_limit_with_retry_after(): +def test_404_maps_to_api_error_with_code(): err = error_from_response( - make_response(429, {"detail": "Rate limit exceeded"}, headers={"retry-after": "7"}) + make_response(404, {"code": "not_found", "message": "Task not found"}) + ) + assert isinstance(err, APIError) + assert err.status_code == 404 + assert err.code == "not_found" + + +def test_422_maps_to_bad_request_with_errors_array(): + body = { + "code": "unprocessable_entity", + "message": "Input should be less than or equal to 180", + "errors": [ + { + "loc": ["body", "duration"], + "msg": "Input should be less than or equal to 180", + "type": "less_than_equal", + } + ], + } + err = error_from_response(make_response(422, body)) + assert isinstance(err, BadRequestError) + assert "Input should be less than or equal to 180" in str(err) + assert err.errors is not None + assert len(err.errors) == 1 + assert err.errors[0]["msg"] == "Input should be less than or equal to 180" + + +def test_429_maps_to_rate_limit_with_retry_after_and_code(): + err = error_from_response( + make_response( + 429, + {"code": "rate_limit_exceeded", "message": "Rate limit exceeded"}, + headers={"retry-after": "30"}, + ) ) assert isinstance(err, RateLimitError) - assert err.retry_after == 7.0 + assert err.retry_after == 30.0 + assert err.code == "rate_limit_exceeded" def test_429_without_header_has_no_retry_after(): - err = error_from_response(make_response(429, {"detail": "Rate limit exceeded"})) + err = error_from_response(make_response(429, {"code": "rate_limit_exceeded", "message": "Rate limit exceeded"})) assert err.retry_after is None @pytest.mark.parametrize("status", [400, 413, 422]) -def test_4xx_maps_to_bad_request_with_detail(status): +def test_4xx_maps_to_bad_request_with_legacy_detail(status): err = error_from_response(make_response(status, {"detail": "bad input"})) assert isinstance(err, BadRequestError) assert err.detail == "bad input" + assert "bad input" in str(err) + + +def test_body_without_message_or_detail_falls_back_to_reason_phrase(): + err = error_from_response(make_response(422, {})) + assert "Unprocessable Entity" in str(err) def test_other_status_maps_to_api_error_with_text_body(): From 697607842ea7bf8a15328dbdcbab0f46e7648fef Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 12 Jul 2026 11:26:18 -0700 Subject: [PATCH 18/18] docs: drop the License section from the README --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index 010ea42..2c0130f 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,3 @@ Every `APIError` also carries `.status_code`, `.body` (the parsed response), `.code` (the API's error code, e.g. `"rate_limit_exceeded"`), and `.errors` (the validation detail list on a 422), in addition to any subclass-specific attributes above. - -## License - -MIT