diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7b6ba4e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: pip install -e ".[dev]" + - run: pytest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..b902d38 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,22 @@ +name: Publish to PyPI + +on: + workflow_dispatch: + push: + tags: ["v*"] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -e ".[dev]" build twine + - run: pytest + - run: python -m build + - run: twine upload dist/* + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} diff --git a/README.md b/README.md index 06719cd..9586776 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,87 @@ # sonilo -Official Python client for the Sonilo API. +Official Python client for the [Sonilo](https://sonilo.com) API. +Python ≥ 3.9. Sync and async clients included. -Work in progress — see docs coming with the first release. +## Installation + +```bash +pip install sonilo +``` + +## Quickstart + +```python +from sonilo import Sonilo + +client = Sonilo() # reads SONILO_API_KEY + +track = client.text_to_music.generate( + prompt="cinematic orchestral score", + duration=60, +) +track.save("output.mp3") +print(track.title) +``` + +## Video to music + +```python +track = client.video_to_music.generate(video="my_video.mp4", prompt="upbeat") +# or bytes / an open binary file, or a hosted URL: +track = client.video_to_music.generate(video_url="https://example.com/clip.mp4") +``` + +## Streaming + +```python +for event in client.text_to_music.stream(prompt="lofi", duration=30): + if event["type"] == "audio_chunk": + handle(event["data"]) # bytes, as they arrive +``` + +## Async + +```python +from sonilo import AsyncSonilo + +async with AsyncSonilo() as client: + track = await client.text_to_music.generate(prompt="lofi", duration=30) + async for event in client.text_to_music.stream(prompt="lofi", duration=30): + ... +``` + +## Segments + +Shape the composition with start-only contiguous segments (each ends where +the next begins): + +```python +client.text_to_music.generate( + prompt="epic trailer", + duration=60, + segments=[ + {"start": 0, "prompt": "soft intro", "label": "intro"}, + {"start": 20, "prompt": "building tension", "label": "verse"}, + {"start": 40, "prompt": "full orchestra", "label": "chorus"}, + ], +) +``` + +## Account + +```python +client.account.services() +client.account.usage(days=7) +``` + +## Errors + +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. + +## License + +MIT diff --git a/examples/generate.py b/examples/generate.py new file mode 100644 index 0000000..ba9712f --- /dev/null +++ b/examples/generate.py @@ -0,0 +1,14 @@ +"""Usage: SONILO_API_KEY=sk_... python examples/generate.py "lofi beat" 30""" +import sys + +from sonilo import Sonilo + +prompt = sys.argv[1] if len(sys.argv) > 1 else "cinematic orchestral score" +duration = int(sys.argv[2]) if len(sys.argv) > 2 else 60 + +with Sonilo() as client: + track = client.text_to_music.generate(prompt=prompt, duration=duration) + +out = track.save("output.mp3") +title = f' — "{track.title}"' if track.title else "" +print(f"Saved {out} ({len(track.audio)} bytes){title}") diff --git a/src/sonilo/__init__.py b/src/sonilo/__init__.py index 5999288..5d872cf 100644 --- a/src/sonilo/__init__.py +++ b/src/sonilo/__init__.py @@ -1,3 +1,29 @@ +from sonilo._async_client import AsyncSonilo +from sonilo._client import Sonilo from sonilo._version import __version__ +from sonilo.errors import ( + APIError, + AuthenticationError, + BadRequestError, + GenerationError, + PaymentRequiredError, + RateLimitError, + SoniloError, +) +from sonilo.types import Segment, StreamEvent, Track -__all__ = ["__version__"] +__all__ = [ + "APIError", + "AsyncSonilo", + "AuthenticationError", + "BadRequestError", + "GenerationError", + "PaymentRequiredError", + "RateLimitError", + "Segment", + "Sonilo", + "SoniloError", + "StreamEvent", + "Track", + "__version__", +] diff --git a/src/sonilo/_async_client.py b/src/sonilo/_async_client.py new file mode 100644 index 0000000..9321462 --- /dev/null +++ b/src/sonilo/_async_client.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from typing import Any, AsyncIterator, Dict, Optional + +import httpx + +from sonilo._client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, _default_headers, _resolve_api_key +from sonilo._streaming import aiter_events +from sonilo.errors import error_from_response +from sonilo.resources.account import AsyncAccount +from sonilo.resources.text_to_music import AsyncTextToMusic +from sonilo.resources.video_to_music import AsyncVideoToMusic +from sonilo.types import StreamEvent + + +class AsyncSonilo: + """Asynchronous Sonilo API client.""" + + def __init__( + self, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + timeout: float = DEFAULT_TIMEOUT, + ) -> None: + key = _resolve_api_key(api_key) + self._http = httpx.AsyncClient( + base_url=(base_url or DEFAULT_BASE_URL).rstrip("/"), + headers=_default_headers(key, "sdk-python"), + timeout=timeout, + ) + self.text_to_music = AsyncTextToMusic(self) + self.video_to_music = AsyncVideoToMusic(self) + self.account = AsyncAccount(self) + + async def close(self) -> None: + await self._http.aclose() + + async def __aenter__(self) -> "AsyncSonilo": + return self + + async def __aexit__(self, *exc_info: Any) -> None: + await self.close() + + # -- internal transport ------------------------------------------------- + + async def _get_json(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any: + response = await self._http.get(path, params=params) + if response.status_code >= 400: + raise error_from_response(response) + return response.json() + + async def _stream_events( + self, + path: str, + *, + data: Dict[str, str], + files: Optional[Dict[str, tuple]] = None, + close_after: Any = None, + ) -> AsyncIterator[StreamEvent]: + try: + async with self._http.stream("POST", path, data=data, files=files) as response: + if response.status_code >= 400: + await response.aread() + raise error_from_response(response) + async for event in aiter_events(response.aiter_text()): + yield event + finally: + if close_after is not None: + close_after.close() diff --git a/src/sonilo/_client.py b/src/sonilo/_client.py new file mode 100644 index 0000000..2cfafdc --- /dev/null +++ b/src/sonilo/_client.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import os +from typing import Any, Dict, Iterator, Optional + +import httpx + +from sonilo._streaming import iter_events +from sonilo._version import __version__ +from sonilo.errors import SoniloError, error_from_response +from sonilo.resources.account import Account +from sonilo.resources.text_to_music import TextToMusic +from sonilo.resources.video_to_music import VideoToMusic +from sonilo.types import StreamEvent + +DEFAULT_BASE_URL = "https://api.sonilo.com" +DEFAULT_TIMEOUT = 600.0 + + +def _resolve_api_key(api_key: Optional[str]) -> str: + key = api_key or os.environ.get("SONILO_API_KEY") + if not key: + raise SoniloError( + "Missing API key: pass api_key= or set the SONILO_API_KEY environment variable" + ) + return key + + +def _default_headers(api_key: str, client_name: str) -> Dict[str, str]: + return { + "Authorization": f"Bearer {api_key}", + "X-Sonilo-Client": client_name, + "X-Sonilo-Client-Version": __version__, + } + + +class Sonilo: + """Synchronous Sonilo API client.""" + + def __init__( + self, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + timeout: float = DEFAULT_TIMEOUT, + ) -> None: + key = _resolve_api_key(api_key) + self._http = httpx.Client( + base_url=(base_url or DEFAULT_BASE_URL).rstrip("/"), + headers=_default_headers(key, "sdk-python"), + timeout=timeout, + ) + self.text_to_music = TextToMusic(self) + self.video_to_music = VideoToMusic(self) + self.account = Account(self) + + def close(self) -> None: + self._http.close() + + def __enter__(self) -> "Sonilo": + return self + + def __exit__(self, *exc_info: Any) -> None: + self.close() + + # -- internal transport ------------------------------------------------- + + def _get_json(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any: + response = self._http.get(path, params=params) + if response.status_code >= 400: + raise error_from_response(response) + return response.json() + + def _stream_events( + self, + path: str, + *, + data: Dict[str, str], + files: Optional[Dict[str, tuple]] = None, + close_after: Any = None, + ) -> Iterator[StreamEvent]: + try: + with self._http.stream("POST", path, data=data, files=files) as response: + if response.status_code >= 400: + response.read() + raise error_from_response(response) + for event in iter_events(response.iter_text()): + yield event + finally: + if close_after is not None: + close_after.close() diff --git a/src/sonilo/_requests.py b/src/sonilo/_requests.py new file mode 100644 index 0000000..19a6905 --- /dev/null +++ b/src/sonilo/_requests.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from sonilo.errors import SoniloError +from sonilo.types import Segment + +DEFAULT_FILENAME = "video.mp4" + + +def build_t2m_data( + prompt: str, duration: int, segments: Optional[List[Segment]] +) -> Dict[str, str]: + data = {"prompt": prompt, "duration": str(duration)} + if segments is not None: + data["segments"] = json.dumps(segments) + return data + + +def normalize_video(video: Any) -> Tuple[str, Any, bool]: + """Normalize a video input into (filename, httpx-uploadable, opened_here). + + Accepts a filesystem path (str/Path — opened for streaming upload; the + caller must close it, signalled by opened_here=True), raw bytes, or a + binary file-like object. + """ + if isinstance(video, (str, Path)): + path = Path(video) + return path.name or DEFAULT_FILENAME, path.open("rb"), True + if isinstance(video, bytes): + return DEFAULT_FILENAME, video, False + if hasattr(video, "read"): + raw_name = getattr(video, "name", None) + filename = Path(raw_name).name if isinstance(raw_name, str) and raw_name else DEFAULT_FILENAME + return filename, video, False + raise SoniloError("Unsupported video input: pass a path, bytes, or a binary file object") + + +def build_v2m_parts( + video: Any, + video_url: Optional[str], + prompt: Optional[str], + segments: Optional[List[Segment]], +) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]: + if (video is None) == (video_url is None): + raise SoniloError("Provide exactly one of video or video_url") + + # Assemble data dict completely before opening any files + data: Dict[str, str] = {} + if video_url is not None: + data["video_url"] = video_url # type: ignore[assignment] + if prompt is not None: + data["prompt"] = prompt + if segments is not None: + data["segments"] = json.dumps(segments) + + # Now open files (only after data is fully assembled) + files: Optional[Dict[str, tuple]] = None + opened = False + if video is not None: + filename, fileobj, opened = normalize_video(video) + files = {"video": (filename, fileobj, "video/mp4")} + + return data, files, opened diff --git a/src/sonilo/_streaming.py b/src/sonilo/_streaming.py new file mode 100644 index 0000000..40aff14 --- /dev/null +++ b/src/sonilo/_streaming.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import base64 +import json +from typing import AsyncIterable, AsyncIterator, Dict, Iterable, Iterator, List, Optional + +from sonilo.errors import GenerationError +from sonilo.types import StreamEvent, Track + + +def _parse_line(line: str) -> StreamEvent: + event = json.loads(line) + if event.get("type") == "audio_chunk" and isinstance(event.get("data"), str): + event = {**event, "data": base64.b64decode(event["data"])} + return event + + +class _LineBuffer: + """Accumulates text chunks and yields complete NDJSON lines.""" + + def __init__(self) -> None: + self._buf = "" + + def feed(self, text: str) -> Iterator[StreamEvent]: + self._buf += text + while True: + idx = self._buf.find("\n") + if idx == -1: + return + line, self._buf = self._buf[:idx].strip(), self._buf[idx + 1 :] + if line: + yield _parse_line(line) + + def flush(self) -> Iterator[StreamEvent]: + line, self._buf = self._buf.strip(), "" + if line: + yield _parse_line(line) + + +def iter_events(text_chunks: Iterable[str]) -> Iterator[StreamEvent]: + buf = _LineBuffer() + for chunk in text_chunks: + yield from buf.feed(chunk) + yield from buf.flush() + + +async def aiter_events(text_chunks: AsyncIterable[str]) -> AsyncIterator[StreamEvent]: + buf = _LineBuffer() + async for chunk in text_chunks: + for event in buf.feed(chunk): + yield event + for event in buf.flush(): + yield event + + +class _TrackBuilder: + def __init__(self) -> None: + self._chunks: List[bytes] = [] + self._title: Optional[str] = None + self._cost: Optional[Dict[str, str]] = None + self._complete = False + + def add(self, event: StreamEvent) -> None: + event_type = event.get("type") + if event_type == "audio_chunk" and isinstance(event.get("data"), bytes): + self._chunks.append(event["data"]) + elif event_type == "title" and isinstance(event.get("title"), str): + self._title = event["title"] + 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" + code = event.get("code") + raise GenerationError(str(message), code=code if isinstance(code, str) else None) + elif event_type == "complete": + self._complete = True + # unknown event types: ignored + + def build(self) -> Track: + if not self._complete: + raise GenerationError("stream ended before a 'complete' event (truncated response)") + return Track(audio=b"".join(self._chunks), title=self._title, cost=self._cost) + + +def collect_track(events: Iterable[StreamEvent]) -> Track: + builder = _TrackBuilder() + try: + for event in events: + builder.add(event) + finally: + close = getattr(events, "close", None) + if close is not None: + close() + return builder.build() + + +async def acollect_track(events: AsyncIterable[StreamEvent]) -> Track: + builder = _TrackBuilder() + try: + async for event in events: + builder.add(event) + finally: + aclose = getattr(events, "aclose", None) + if aclose is not None: + await aclose() + return builder.build() diff --git a/src/sonilo/errors.py b/src/sonilo/errors.py new file mode 100644 index 0000000..773008b --- /dev/null +++ b/src/sonilo/errors.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from typing import Any, Optional + +import httpx + + +class SoniloError(Exception): + """Base class for every error raised by this SDK.""" + + +class APIError(SoniloError): + def __init__(self, message: str, status_code: int, body: Any = None) -> None: + super().__init__(message) + self.status_code = status_code + self.body = body + + +class AuthenticationError(APIError): + pass + + +class PaymentRequiredError(APIError): + pass + + +class BadRequestError(APIError): + @property + def detail(self) -> Optional[str]: + if isinstance(self.body, dict): + detail = self.body.get("detail") + if isinstance(detail, str): + return detail + return None + + +class RateLimitError(APIError): + def __init__( + self, + message: str, + status_code: int, + body: Any = None, + retry_after: Optional[float] = None, + ) -> None: + super().__init__(message, status_code, body) + self.retry_after = retry_after + + +class GenerationError(SoniloError): + """Raised by generate() when an `error` event arrives mid-stream.""" + + def __init__(self, message: str, code: Optional[str] = None) -> None: + super().__init__(message) + self.code = code + + +def error_from_response(response: httpx.Response) -> APIError: + """Map a non-2xx response to a typed error. + + For streamed responses the body must already have been read + (`response.read()` / `await response.aread()`). + """ + try: + 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'}" + status = response.status_code + + if status == 401: + return AuthenticationError(message, status, body) + if status == 402: + return PaymentRequiredError(message, status, body) + if status == 429: + raw = response.headers.get("retry-after") + retry_after: Optional[float] + try: + retry_after = float(raw) if raw is not None else None + except ValueError: + retry_after = None + return RateLimitError(message, status, body, retry_after=retry_after) + if status in (400, 413, 422): + return BadRequestError(message, status, body) + return APIError(message, status, body) diff --git a/src/sonilo/py.typed b/src/sonilo/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/sonilo/resources/__init__.py b/src/sonilo/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sonilo/resources/account.py b/src/sonilo/resources/account.py new file mode 100644 index 0000000..bb42534 --- /dev/null +++ b/src/sonilo/resources/account.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, Optional + +if TYPE_CHECKING: + from sonilo._async_client import AsyncSonilo + from sonilo._client import Sonilo + + +class Account: + def __init__(self, client: "Sonilo") -> None: + self._client = client + + def services(self) -> Dict[str, Any]: + return self._client._get_json("/v1/account/services") + + def usage(self, *, days: Optional[int] = None) -> Dict[str, Any]: + params = {"days": days} if days is not None else None + return self._client._get_json("/v1/account/usage", params=params) + + +class AsyncAccount: + def __init__(self, client: "AsyncSonilo") -> None: + self._client = client + + async def services(self) -> Dict[str, Any]: + return await self._client._get_json("/v1/account/services") + + async def usage(self, *, days: Optional[int] = None) -> Dict[str, Any]: + params = {"days": days} if days is not None else None + return await self._client._get_json("/v1/account/usage", params=params) diff --git a/src/sonilo/resources/text_to_music.py b/src/sonilo/resources/text_to_music.py new file mode 100644 index 0000000..ad64495 --- /dev/null +++ b/src/sonilo/resources/text_to_music.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, AsyncIterator, Iterator, List, Optional + +from sonilo._requests import build_t2m_data +from sonilo._streaming import acollect_track, collect_track +from sonilo.types import Segment, StreamEvent, Track + +if TYPE_CHECKING: + from sonilo._async_client import AsyncSonilo + from sonilo._client import Sonilo + +PATH = "/v1/text-to-music" + + +class TextToMusic: + def __init__(self, client: "Sonilo") -> None: + self._client = client + + def stream( + self, + *, + prompt: str, + duration: int, + segments: Optional[List[Segment]] = None, + ) -> Iterator[StreamEvent]: + data = build_t2m_data(prompt, duration, segments) + return self._client._stream_events(PATH, data=data) + + def generate( + self, + *, + prompt: str, + duration: int, + segments: Optional[List[Segment]] = None, + ) -> Track: + return collect_track(self.stream(prompt=prompt, duration=duration, segments=segments)) + + +class AsyncTextToMusic: + def __init__(self, client: "AsyncSonilo") -> None: + self._client = client + + def stream( + self, + *, + prompt: str, + duration: int, + segments: Optional[List[Segment]] = None, + ) -> AsyncIterator[StreamEvent]: + data = build_t2m_data(prompt, duration, segments) + return self._client._stream_events(PATH, data=data) + + async def generate( + self, + *, + prompt: str, + duration: int, + segments: Optional[List[Segment]] = None, + ) -> Track: + return await acollect_track( + self.stream(prompt=prompt, duration=duration, segments=segments) + ) diff --git a/src/sonilo/resources/video_to_music.py b/src/sonilo/resources/video_to_music.py new file mode 100644 index 0000000..3f159d3 --- /dev/null +++ b/src/sonilo/resources/video_to_music.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, AsyncIterator, Iterator, List, Optional + +from sonilo._requests import build_v2m_parts +from sonilo._streaming import acollect_track, collect_track +from sonilo.types import Segment, StreamEvent, Track + +if TYPE_CHECKING: + from sonilo._async_client import AsyncSonilo + from sonilo._client import Sonilo + +PATH = "/v1/video-to-music" + + +class VideoToMusic: + def __init__(self, client: "Sonilo") -> None: + self._client = client + + def stream( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + prompt: Optional[str] = None, + segments: Optional[List[Segment]] = None, + ) -> Iterator[StreamEvent]: + data, files, opened = build_v2m_parts(video, video_url, prompt, segments) + close_after = files["video"][1] if files is not None and opened else None + return self._client._stream_events(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[Segment]] = None, + ) -> Track: + return collect_track( + self.stream(video=video, video_url=video_url, prompt=prompt, segments=segments) + ) + + +class AsyncVideoToMusic: + def __init__(self, client: "AsyncSonilo") -> None: + self._client = client + + def stream( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + prompt: Optional[str] = None, + segments: Optional[List[Segment]] = None, + ) -> AsyncIterator[StreamEvent]: + data, files, opened = build_v2m_parts(video, video_url, prompt, segments) + close_after = files["video"][1] if files is not None and opened else None + return self._client._stream_events(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[Segment]] = None, + ) -> Track: + return await acollect_track( + self.stream(video=video, video_url=video_url, prompt=prompt, segments=segments) + ) diff --git a/src/sonilo/types.py b/src/sonilo/types.py new file mode 100644 index 0000000..95342fb --- /dev/null +++ b/src/sonilo/types.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Optional, Union + +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.""" + + +@dataclass +class Track: + audio: bytes + title: Optional[str] = None + cost: Optional[Dict[str, str]] = None + + def save(self, path: Union[str, Path]) -> Path: + """Write the audio bytes to `path` and return it.""" + p = Path(path) + p.write_bytes(self.audio) + return p diff --git a/tests/test_async_client.py b/tests/test_async_client.py new file mode 100644 index 0000000..26a1db8 --- /dev/null +++ b/tests/test_async_client.py @@ -0,0 +1,87 @@ +import base64 +import json + +import httpx +import pytest +import respx + +from sonilo import AsyncSonilo +from sonilo.errors import AuthenticationError, GenerationError, SoniloError + +BASE = "https://api.sonilo.com" + + +def b64(data: bytes) -> str: + return base64.b64encode(data).decode() + + +def ndjson(*events) -> bytes: + return ("".join(json.dumps(e) + "\n" for e in events)).encode() + + +STREAM_EVENTS = ( + {"type": "title", "title": "Skyline"}, + {"type": "audio_chunk", "data": b64(b"abc")}, + {"type": "complete"}, +) + + +@respx.mock +async def test_generate_buffers_track(): + respx.post(f"{BASE}/v1/text-to-music").mock( + return_value=httpx.Response(200, content=ndjson(*STREAM_EVENTS)) + ) + async with AsyncSonilo(api_key="sk_test") as client: + track = await client.text_to_music.generate(prompt="p", duration=10) + assert track.audio == b"abc" + assert track.title == "Skyline" + + +@respx.mock +async def test_stream_yields_events(): + respx.post(f"{BASE}/v1/text-to-music").mock( + return_value=httpx.Response(200, content=ndjson(*STREAM_EVENTS)) + ) + async with AsyncSonilo(api_key="sk_test") as client: + events = [e async for e in client.text_to_music.stream(prompt="p", duration=10)] + assert [e["type"] for e in events] == ["title", "audio_chunk", "complete"] + assert events[1]["data"] == b"abc" + + +@respx.mock +async def test_error_event_raises_generation_error(): + respx.post(f"{BASE}/v1/text-to-music").mock( + return_value=httpx.Response(200, content=ndjson({"type": "error", "message": "boom"})) + ) + async with AsyncSonilo(api_key="sk_test") as client: + with pytest.raises(GenerationError): + await client.text_to_music.generate(prompt="p", duration=10) + + +@respx.mock +async def test_http_error_maps(): + respx.post(f"{BASE}/v1/video-to-music").mock( + return_value=httpx.Response(401, json={"detail": "Invalid API key"}) + ) + async with AsyncSonilo(api_key="sk_test") as client: + with pytest.raises(AuthenticationError): + await client.video_to_music.generate(video=b"x") + + +async def test_video_xor_validation(): + async with AsyncSonilo(api_key="sk_test") as client: + with pytest.raises(SoniloError): + await client.video_to_music.generate() + + +@respx.mock +async def test_account_endpoints(): + respx.get(f"{BASE}/v1/account/services").mock( + return_value=httpx.Response(200, json={"rpm_limit": 60}) + ) + respx.get(f"{BASE}/v1/account/usage").mock( + return_value=httpx.Response(200, json={"summary": {}, "daily": []}) + ) + async with AsyncSonilo(api_key="sk_test") as client: + assert (await client.account.services())["rpm_limit"] == 60 + assert await client.account.usage() == {"summary": {}, "daily": []} diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..9db8bcc --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,65 @@ +import httpx +import pytest + +from sonilo.errors import ( + APIError, + AuthenticationError, + BadRequestError, + GenerationError, + PaymentRequiredError, + RateLimitError, + SoniloError, + error_from_response, +) + + +def make_response(status_code, json_body=None, text=None, headers=None): + if json_body is not None: + return httpx.Response(status_code, json=json_body, headers=headers) + return httpx.Response(status_code, text=text or "", headers=headers) + + +def test_401_maps_to_authentication_error(): + err = error_from_response(make_response(401, {"detail": "Invalid API key"})) + assert isinstance(err, AuthenticationError) + assert err.status_code == 401 + assert "Invalid API key" in str(err) + + +def test_402_maps_to_payment_required(): + err = error_from_response(make_response(402, {"detail": "Insufficient balance"})) + assert isinstance(err, PaymentRequiredError) + + +def test_429_maps_to_rate_limit_with_retry_after(): + err = error_from_response( + make_response(429, {"detail": "Rate limit exceeded"}, headers={"retry-after": "7"}) + ) + assert isinstance(err, RateLimitError) + assert err.retry_after == 7.0 + + +def test_429_without_header_has_no_retry_after(): + err = error_from_response(make_response(429, {"detail": "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): + err = error_from_response(make_response(status, {"detail": "bad input"})) + assert isinstance(err, BadRequestError) + assert err.detail == "bad input" + + +def test_other_status_maps_to_api_error_with_text_body(): + err = error_from_response(make_response(500, text="boom")) + assert isinstance(err, APIError) + assert not isinstance(err, BadRequestError) + assert err.status_code == 500 + assert err.body == "boom" + + +def test_generation_error_carries_code(): + err = GenerationError("failed", code="PROXY_ERROR") + assert isinstance(err, SoniloError) + assert err.code == "PROXY_ERROR" diff --git a/tests/test_requests.py b/tests/test_requests.py new file mode 100644 index 0000000..ba971aa --- /dev/null +++ b/tests/test_requests.py @@ -0,0 +1,85 @@ +import io +import json + +import pytest + +from sonilo._requests import build_t2m_data, build_v2m_parts, normalize_video +from sonilo.errors import SoniloError + + +def test_build_t2m_data_basic(): + data = build_t2m_data("lofi beat", 30, None) + assert data == {"prompt": "lofi beat", "duration": "30"} + + +def test_build_t2m_data_with_segments(): + segments = [{"start": 0, "prompt": "intro", "label": "intro"}] + data = build_t2m_data("p", 60, segments) + assert json.loads(data["segments"]) == segments + + +def test_normalize_video_path(tmp_path): + path = tmp_path / "movie.mp4" + path.write_bytes(b"vid") + filename, fileobj, opened = normalize_video(str(path)) + try: + assert filename == "movie.mp4" + assert opened is True + assert fileobj.read() == b"vid" + finally: + fileobj.close() + + +def test_normalize_video_bytes(): + filename, fileobj, opened = normalize_video(b"vid") + assert filename == "video.mp4" + assert fileobj == b"vid" + assert opened is False + + +def test_normalize_video_file_like(): + src = io.BytesIO(b"vid") + src.name = "clip.mp4" + filename, fileobj, opened = normalize_video(src) + assert filename == "clip.mp4" + assert fileobj is src + assert opened is False + + +def test_normalize_video_rejects_unsupported(): + with pytest.raises(SoniloError): + normalize_video(42) + + +def test_build_v2m_parts_with_url(): + data, files, opened = build_v2m_parts(None, "https://example.com/v.mp4", "upbeat", None) + assert data == {"video_url": "https://example.com/v.mp4", "prompt": "upbeat"} + assert files is None + assert opened is False + + +def test_build_v2m_parts_with_bytes(): + data, files, opened = build_v2m_parts(b"vid", None, None, None) + assert data == {} + assert files["video"][0] == "video.mp4" + assert files["video"][1] == b"vid" + + +def test_build_v2m_parts_rejects_both_and_neither(): + with pytest.raises(SoniloError): + build_v2m_parts(b"vid", "https://example.com/v.mp4", None, None) + with pytest.raises(SoniloError): + build_v2m_parts(None, None, None, None) + + +def test_build_v2m_parts_with_path_propagates_opened(tmp_path): + path = tmp_path / "clip.mp4" + path.write_bytes(b"vid") + data, files, opened = build_v2m_parts(str(path), None, None, None) + try: + assert opened is True + assert files["video"][0] == "clip.mp4" + assert files["video"][1].read() == b"vid" + assert data == {} + finally: + files["video"][1].close() diff --git a/tests/test_streaming.py b/tests/test_streaming.py new file mode 100644 index 0000000..15b9db0 --- /dev/null +++ b/tests/test_streaming.py @@ -0,0 +1,166 @@ +import base64 +import json + +import pytest + +from sonilo._streaming import acollect_track, aiter_events, collect_track, iter_events +from sonilo.errors import GenerationError +from sonilo.types import Track + + +def b64(data: bytes) -> str: + return base64.b64encode(data).decode() + + +LINES = ( + json.dumps({"type": "title", "title": "Skyline", "summary": "s"}) + + "\n" + + json.dumps({"type": "audio_chunk", "data": b64(b"abc")}) + + "\n" + + json.dumps({"type": "audio_chunk", "data": b64(b"def")}) + + "\n" + + json.dumps({"type": "complete"}) + + "\n" +) + + +def chunked(text: str, size: int): + return [text[i : i + size] for i in range(0, len(text), size)] + + +async def as_async_iter(items): + for item in items: + yield item + + +def test_iter_events_whole_chunks(): + events = list(iter_events([LINES])) + assert [e["type"] for e in events] == ["title", "audio_chunk", "audio_chunk", "complete"] + + +def test_iter_events_one_char_chunks(): + events = list(iter_events(chunked(LINES, 1))) + assert [e["type"] for e in events] == ["title", "audio_chunk", "audio_chunk", "complete"] + + +def test_audio_chunk_data_is_decoded_bytes(): + events = list(iter_events(chunked(LINES, 7))) + assert events[1]["data"] == b"abc" + + +def test_trailing_line_without_newline(): + events = list(iter_events([json.dumps({"type": "complete"})])) + assert events == [{"type": "complete"}] + + +def test_empty_lines_skipped(): + events = list(iter_events(["\n\n" + json.dumps({"type": "complete"}) + "\n\n"])) + assert events == [{"type": "complete"}] + + +def test_unknown_event_passed_through(): + events = list(iter_events([json.dumps({"type": "stage_start", "stage": "analyze"}) + "\n"])) + assert events == [{"type": "stage_start", "stage": "analyze"}] + + +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"] + assert events[1]["data"] == b"abc" + + +COST = { + "type": "cost", + "billing_rate_per_sec": "0.01", + "billing_before_discount": "0.6000", + "billing_after_discount": "0.4800", + "discount_factor": "0.8000", +} + + +def test_collect_track_concatenates_and_captures_metadata(): + text = LINES.replace(json.dumps({"type": "complete"}) + "\n", "") + json.dumps(COST) + "\n" + json.dumps({"type": "complete"}) + "\n" + track = collect_track(iter_events(chunked(text, 11))) + assert isinstance(track, Track) + assert track.audio == b"abcdef" + assert track.title == "Skyline" + assert track.cost == {k: v for k, v in COST.items() if k != "type"} + + +def test_collect_track_ignores_unknown_events(): + text = ( + json.dumps({"type": "stage_start"}) + + "\n" + + json.dumps({"type": "audio_chunk", "data": b64(b"x")}) + + "\n" + + json.dumps({"type": "complete"}) + + "\n" + ) + track = collect_track(iter_events([text])) + assert track.audio == b"x" + assert track.title is None + + +def test_collect_track_raises_generation_error_on_error_event(): + text = ( + json.dumps({"type": "audio_chunk", "data": b64(b"x")}) + + "\n" + + json.dumps({"type": "error", "code": "PROXY_ERROR", "message": "upstream died"}) + + "\n" + ) + with pytest.raises(GenerationError) as excinfo: + collect_track(iter_events([text])) + assert excinfo.value.code == "PROXY_ERROR" + assert "upstream died" in str(excinfo.value) + + +async def test_acollect_track_matches_sync(): + track = await acollect_track(aiter_events(as_async_iter(chunked(LINES, 5)))) + assert track.audio == b"abcdef" + assert track.title == "Skyline" + + +def test_track_save(tmp_path): + out = Track(audio=b"abc").save(tmp_path / "out.mp3") + assert out.read_bytes() == b"abc" + + +def test_collect_track_closes_generator_on_error_event(): + closed = [] + + def gen(): + try: + yield {"type": "error", "message": "boom"} + yield {"type": "complete"} + finally: + closed.append(True) + + with pytest.raises(GenerationError): + collect_track(gen()) + assert closed == [True] + + +async def test_acollect_track_closes_generator_on_error_event(): + closed = [] + + async def agen(): + try: + yield {"type": "error", "message": "boom"} + yield {"type": "complete"} + finally: + closed.append(True) + + with pytest.raises(GenerationError): + await acollect_track(agen()) + assert closed == [True] + + +def test_collect_track_raises_on_missing_complete(): + events = iter_events([json.dumps({"type": "audio_chunk", "data": b64(b"x")}) + "\n"]) + with pytest.raises(GenerationError): + collect_track(events) + + +def test_collect_track_raises_on_empty_stream(): + with pytest.raises(GenerationError): + collect_track(iter_events([])) diff --git a/tests/test_sync_client.py b/tests/test_sync_client.py new file mode 100644 index 0000000..490552b --- /dev/null +++ b/tests/test_sync_client.py @@ -0,0 +1,154 @@ +import base64 +import json + +import httpx +import pytest +import respx + +from sonilo import Sonilo +from sonilo._version import __version__ +from sonilo.errors import AuthenticationError, GenerationError, SoniloError + +BASE = "https://api.sonilo.com" + + +def b64(data: bytes) -> str: + return base64.b64encode(data).decode() + + +def ndjson(*events) -> bytes: + return ("".join(json.dumps(e) + "\n" for e in events)).encode() + +STREAM_EVENTS = ( + {"type": "title", "title": "Skyline"}, + {"type": "audio_chunk", "data": b64(b"abc")}, + {"type": "complete"}, +) + + +def make_client() -> Sonilo: + return Sonilo(api_key="sk_test_123") + + +def test_missing_api_key_raises(monkeypatch): + monkeypatch.delenv("SONILO_API_KEY", raising=False) + with pytest.raises(SoniloError): + Sonilo() + + +def test_env_api_key_used(monkeypatch): + monkeypatch.setenv("SONILO_API_KEY", "sk_env") + client = Sonilo() + assert client._http.headers["authorization"] == "Bearer sk_env" + + +@respx.mock +def test_text_to_music_generate_posts_form_and_buffers(): + route = respx.post(f"{BASE}/v1/text-to-music").mock( + return_value=httpx.Response(200, content=ndjson(*STREAM_EVENTS)) + ) + with make_client() as client: + track = client.text_to_music.generate(prompt="cinematic", duration=60) + assert track.audio == b"abc" + assert track.title == "Skyline" + + request = route.calls.last.request + assert request.headers["authorization"] == "Bearer sk_test_123" + assert request.headers["x-sonilo-client"] == "sdk-python" + assert request.headers["x-sonilo-client-version"] == __version__ + body = request.content.decode() + assert "cinematic" in body + assert "60" in body + + +@respx.mock +def test_text_to_music_stream_yields_events(): + respx.post(f"{BASE}/v1/text-to-music").mock( + return_value=httpx.Response(200, content=ndjson(*STREAM_EVENTS)) + ) + with make_client() as client: + events = list(client.text_to_music.stream(prompt="p", duration=10)) + assert [e["type"] for e in events] == ["title", "audio_chunk", "complete"] + assert events[1]["data"] == b"abc" + + +@respx.mock +def test_generate_raises_generation_error_on_error_event(): + respx.post(f"{BASE}/v1/text-to-music").mock( + return_value=httpx.Response( + 200, content=ndjson({"type": "error", "code": "PROXY_ERROR", "message": "boom"}) + ) + ) + with make_client() as client: + with pytest.raises(GenerationError): + client.text_to_music.generate(prompt="p", duration=10) + + +@respx.mock +def test_http_error_maps_to_typed_error(): + respx.post(f"{BASE}/v1/text-to-music").mock( + return_value=httpx.Response(401, json={"detail": "Invalid API key"}) + ) + with make_client() as client: + with pytest.raises(AuthenticationError): + client.text_to_music.generate(prompt="p", duration=10) + + +@respx.mock +def test_video_to_music_uploads_multipart(tmp_path): + path = tmp_path / "clip.mp4" + path.write_bytes(b"fakevideo") + route = respx.post(f"{BASE}/v1/video-to-music").mock( + return_value=httpx.Response(200, content=ndjson(*STREAM_EVENTS)) + ) + with make_client() as client: + track = client.video_to_music.generate(video=str(path), prompt="upbeat") + assert track.audio == b"abc" + body = route.calls.last.request.content + assert b"clip.mp4" in body + assert b"fakevideo" in body + assert b"upbeat" in body + + +@respx.mock +def test_video_to_music_url_variant(): + route = respx.post(f"{BASE}/v1/video-to-music").mock( + return_value=httpx.Response(200, content=ndjson(*STREAM_EVENTS)) + ) + with make_client() as client: + client.video_to_music.generate(video_url="https://example.com/v.mp4") + assert b"video_url" in route.calls.last.request.content + + +def test_video_xor_validation(): + with make_client() as client: + with pytest.raises(SoniloError): + list(client.video_to_music.stream(video=b"x", video_url="https://e.com/v.mp4")) + with pytest.raises(SoniloError): + list(client.video_to_music.stream()) + + +@respx.mock +def test_stream_yields_error_event_without_raising(): + respx.post(f"{BASE}/v1/text-to-music").mock( + return_value=httpx.Response( + 200, content=ndjson({"type": "error", "code": "PROXY_ERROR", "message": "boom"}) + ) + ) + with make_client() as client: + events = list(client.text_to_music.stream(prompt="p", duration=10)) + assert events == [{"type": "error", "code": "PROXY_ERROR", "message": "boom"}] + + +@respx.mock +def test_account_endpoints(): + respx.get(f"{BASE}/v1/account/services").mock( + return_value=httpx.Response(200, json={"rpm_limit": 60}) + ) + usage_route = respx.get(f"{BASE}/v1/account/usage", params={"days": 7}).mock( + return_value=httpx.Response(200, json={"summary": {}, "daily": []}) + ) + with make_client() as client: + assert client.account.services()["rpm_limit"] == 60 + assert client.account.usage(days=7) == {"summary": {}, "daily": []} + assert usage_route.called