diff --git a/pyproject.toml b/pyproject.toml index 91e01c5..fd4e058 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo" -version = "0.5.0" +version = "0.5.1" description = "Official Python client for the Sonilo API" readme = "README.md" license = "MIT" diff --git a/sonilo-cli/pyproject.toml b/sonilo-cli/pyproject.toml index 2be4520..f20eb40 100644 --- a/sonilo-cli/pyproject.toml +++ b/sonilo-cli/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo-cli" -version = "0.1.0" +version = "0.1.1" description = "Command-line interface for the Sonilo API: generate music and sound effects from text or video" readme = "README.md" license = "MIT" diff --git a/sonilo-cli/src/sonilo_cli/__init__.py b/sonilo-cli/src/sonilo_cli/__init__.py index de48b44..0dc5d6a 100644 --- a/sonilo-cli/src/sonilo_cli/__init__.py +++ b/sonilo-cli/src/sonilo_cli/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" __all__ = ["__version__"] diff --git a/sonilo-cli/src/sonilo_cli/__main__.py b/sonilo-cli/src/sonilo_cli/__main__.py index 29fce0e..2eb8b4a 100644 --- a/sonilo-cli/src/sonilo_cli/__main__.py +++ b/sonilo-cli/src/sonilo_cli/__main__.py @@ -43,7 +43,9 @@ def build_client(api_key: Optional[str]) -> Sonilo: "no API key — pass --api-key or set the " "SONILO_API_KEY environment variable" ) - return Sonilo(api_key=key) + # Identify as the CLI rather than inheriting the SDK's own name, so CLI + # traffic stays separable from direct SDK use in server-side analytics. + return Sonilo(api_key=key, client_name="cli-python", client_version=__version__) def cmd_account(client: Sonilo, args: argparse.Namespace) -> None: diff --git a/sonilo-cli/tests/test_cli.py b/sonilo-cli/tests/test_cli.py index 9d7d5ff..f8e959c 100644 --- a/sonilo-cli/tests/test_cli.py +++ b/sonilo-cli/tests/test_cli.py @@ -369,3 +369,16 @@ def test_video_to_video_sound_requires_a_video_source(): with pytest.raises(SystemExit) as exc: run(["video-to-video-sound"]) assert exc.value.code == 1 + + +def test_cli_identifies_itself_not_the_sdk(): + """CLI traffic must be separable from direct SDK use in analytics.""" + import sonilo_cli + from sonilo_cli.__main__ import build_client + + client = build_client("sk-test") + try: + assert client._http.headers["x-sonilo-client"] == "cli-python" + assert client._http.headers["x-sonilo-client-version"] == sonilo_cli.__version__ + finally: + client.close() diff --git a/sonilo-video-kit/pyproject.toml b/sonilo-video-kit/pyproject.toml index d0dd737..2aaefaf 100644 --- a/sonilo-video-kit/pyproject.toml +++ b/sonilo-video-kit/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo-video-kit" -version = "0.1.1" +version = "0.1.2" description = "Video helpers for the Sonilo API: generate a soundtrack and mix it into your video with ffmpeg" readme = "README.md" license = "MIT" diff --git a/sonilo-video-kit/src/sonilo_video_kit/_version.py b/sonilo-video-kit/src/sonilo_video_kit/_version.py new file mode 100644 index 0000000..b3f4756 --- /dev/null +++ b/sonilo-video-kit/src/sonilo_video_kit/_version.py @@ -0,0 +1 @@ +__version__ = "0.1.2" diff --git a/sonilo-video-kit/src/sonilo_video_kit/duck.py b/sonilo-video-kit/src/sonilo_video_kit/duck.py index 32fea36..c56f07b 100644 --- a/sonilo-video-kit/src/sonilo_video_kit/duck.py +++ b/sonilo-video-kit/src/sonilo_video_kit/duck.py @@ -72,7 +72,11 @@ def effective_download_cap(output_bytes: Optional[int]) -> int: def _default_client() -> DuckingClient: from sonilo import Sonilo - return Sonilo() + from sonilo_video_kit._version import __version__ + + # Only the kit's own default client is tagged; a caller-supplied client + # keeps whatever identity its owner gave it. + return Sonilo(client_name="videokit-python", client_version=__version__) def _paid_note(task_id: str) -> str: diff --git a/sonilo-video-kit/src/sonilo_video_kit/generate.py b/sonilo-video-kit/src/sonilo_video_kit/generate.py index f3eff7e..f5245c7 100644 --- a/sonilo-video-kit/src/sonilo_video_kit/generate.py +++ b/sonilo-video-kit/src/sonilo_video_kit/generate.py @@ -23,5 +23,9 @@ def generate_music_for_video( ) -> Any: if client is None: from sonilo import Sonilo - client = Sonilo() + + from sonilo_video_kit._version import __version__ + # Only the kit's own default client is tagged; a caller-supplied client + # keeps whatever identity its owner gave it. + client = Sonilo(client_name="videokit-python", client_version=__version__) return client.video_to_music.generate(video=video, prompt=prompt, segments=segments) diff --git a/sonilo-video-kit/tests/test_smoke.py b/sonilo-video-kit/tests/test_smoke.py index 44e058f..624d4b5 100644 --- a/sonilo-video-kit/tests/test_smoke.py +++ b/sonilo-video-kit/tests/test_smoke.py @@ -3,3 +3,20 @@ def test_import(): assert hasattr(sonilo_video_kit, "__all__") + + +def test_default_clients_identify_as_the_video_kit(monkeypatch): + """Only the kit's own default client is tagged; a caller-supplied client + keeps its owner's identity.""" + from sonilo_video_kit._version import __version__ + from sonilo_video_kit.duck import _default_client + + # _default_client() builds Sonilo() with no explicit key, so the SDK reads + # the environment — set one, or this fails wherever no key is configured. + monkeypatch.setenv("SONILO_API_KEY", "sk-test") + client = _default_client() + try: + assert client._http.headers["x-sonilo-client"] == "videokit-python" + assert client._http.headers["x-sonilo-client-version"] == __version__ + finally: + client.close() diff --git a/src/sonilo/_async_client.py b/src/sonilo/_async_client.py index 40a84d4..3dfdc58 100644 --- a/src/sonilo/_async_client.py +++ b/src/sonilo/_async_client.py @@ -28,11 +28,17 @@ def __init__( api_key: Optional[str] = None, base_url: Optional[str] = None, timeout: float = DEFAULT_TIMEOUT, + client_name: Optional[str] = None, + client_version: Optional[str] = None, ) -> None: + """`client_name`/`client_version` identify a wrapper built on this SDK + (the CLI, the video kit) in the `X-Sonilo-Client` headers. Leave both + unset for direct SDK use. + """ 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"), + headers=_default_headers(key, client_name, client_version), timeout=timeout, ) self.text_to_music = AsyncTextToMusic(self) diff --git a/src/sonilo/_client.py b/src/sonilo/_client.py index 200dd4e..540ac7f 100644 --- a/src/sonilo/_client.py +++ b/src/sonilo/_client.py @@ -23,6 +23,11 @@ DEFAULT_BASE_URL = "https://api.sonilo.com" DEFAULT_TIMEOUT = 600.0 +#: Reported in `X-Sonilo-Client` unless a wrapper overrides it. First-party +#: wrappers (the CLI, the video kit) pass their own name so their traffic stays +#: distinguishable from direct SDK use in server-side analytics. +DEFAULT_CLIENT_NAME = "sdk-python" + def _resolve_api_key(api_key: Optional[str]) -> str: key = api_key or os.environ.get("SONILO_API_KEY") @@ -33,11 +38,15 @@ def _resolve_api_key(api_key: Optional[str]) -> str: return key -def _default_headers(api_key: str, client_name: str) -> Dict[str, str]: +def _default_headers( + api_key: str, + client_name: Optional[str] = None, + client_version: Optional[str] = None, +) -> Dict[str, str]: return { "Authorization": f"Bearer {api_key}", - "X-Sonilo-Client": client_name, - "X-Sonilo-Client-Version": __version__, + "X-Sonilo-Client": client_name or DEFAULT_CLIENT_NAME, + "X-Sonilo-Client-Version": client_version or __version__, } @@ -49,11 +58,17 @@ def __init__( api_key: Optional[str] = None, base_url: Optional[str] = None, timeout: float = DEFAULT_TIMEOUT, + client_name: Optional[str] = None, + client_version: Optional[str] = None, ) -> None: + """`client_name`/`client_version` identify a wrapper built on this SDK + (the CLI, the video kit) in the `X-Sonilo-Client` headers. Leave both + unset for direct SDK use. + """ 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"), + headers=_default_headers(key, client_name, client_version), timeout=timeout, ) self.text_to_music = TextToMusic(self) diff --git a/src/sonilo/_version.py b/src/sonilo/_version.py index 3d18726..dd9b22c 100644 --- a/src/sonilo/_version.py +++ b/src/sonilo/_version.py @@ -1 +1 @@ -__version__ = "0.5.0" +__version__ = "0.5.1" diff --git a/tests/test_client_identity.py b/tests/test_client_identity.py new file mode 100644 index 0000000..5ff7757 --- /dev/null +++ b/tests/test_client_identity.py @@ -0,0 +1,82 @@ +"""Client-identity headers. + +First-party wrappers (the CLI, the video kit) sit on top of this SDK, so +without an override every one of their calls reports as `sdk-python` and +becomes indistinguishable from direct SDK use in server-side analytics. +""" + +import httpx +import pytest +import respx + +from sonilo import AsyncSonilo, Sonilo +from sonilo._client import DEFAULT_CLIENT_NAME +from sonilo._version import __version__ + +BASE = "https://api.sonilo.com" +SERVICES = {"available_services": []} + + +def _stub(): + return respx.get(f"{BASE}/v1/account/services").mock( + return_value=httpx.Response(200, json=SERVICES) + ) + + +@respx.mock +def test_defaults_to_sdk_python(): + route = _stub() + Sonilo(api_key="sk-test").account.services() + headers = route.calls.last.request.headers + assert headers["x-sonilo-client"] == "sdk-python" + assert headers["x-sonilo-client-version"] == __version__ + + +def test_default_client_name_constant(): + assert DEFAULT_CLIENT_NAME == "sdk-python" + + +@respx.mock +def test_client_name_and_version_are_overridable(): + route = _stub() + Sonilo(api_key="sk-test", client_name="cli-python", client_version="1.2.3").account.services() + headers = route.calls.last.request.headers + assert headers["x-sonilo-client"] == "cli-python" + assert headers["x-sonilo-client-version"] == "1.2.3" + + +@respx.mock +def test_overrides_are_independent(): + """Naming a wrapper must not force it to also restate a version, and vice versa.""" + route = _stub() + Sonilo(api_key="sk-test", client_name="videokit-python").account.services() + headers = route.calls.last.request.headers + assert headers["x-sonilo-client"] == "videokit-python" + assert headers["x-sonilo-client-version"] == __version__ + + Sonilo(api_key="sk-test", client_version="9.9.9").account.services() + headers = route.calls.last.request.headers + assert headers["x-sonilo-client"] == "sdk-python" + assert headers["x-sonilo-client-version"] == "9.9.9" + + +@respx.mock +@pytest.mark.asyncio +async def test_async_client_honours_the_same_overrides(): + route = _stub() + await AsyncSonilo( + api_key="sk-test", client_name="cli-python", client_version="1.2.3" + ).account.services() + headers = route.calls.last.request.headers + assert headers["x-sonilo-client"] == "cli-python" + assert headers["x-sonilo-client-version"] == "1.2.3" + + +@respx.mock +@pytest.mark.asyncio +async def test_async_defaults_to_sdk_python(): + route = _stub() + await AsyncSonilo(api_key="sk-test").account.services() + headers = route.calls.last.request.headers + assert headers["x-sonilo-client"] == "sdk-python" + assert headers["x-sonilo-client-version"] == __version__