diff --git a/CHANGELOG.md b/CHANGELOG.md index 441896f..ec133fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.9.1] - 2026-09-18 + ### Added +- Every request now carries `X-SudoMock-Client: python-sdk/`, and the + `User-Agent` header sends the same value (it used to read + `sudomock-python/`). The version is the installed package version, + the same value as `sudomock.__version__`. - `Usage.prepaid_balance` and `Usage.prepaid_balance_currency`, the money an account holds and spends per render. An account funded this way has no subscription allowance, so its three `credits_*` fields are legitimately `0` diff --git a/pyproject.toml b/pyproject.toml index ae07f4e..9e28afb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sudomock" -version = "0.9.0" +version = "0.9.1" description = "Official Python SDK for the SudoMock Mockup Generator API" readme = "README.md" license = "MIT" diff --git a/src/sudomock/__init__.py b/src/sudomock/__init__.py index 3517087..2eb80b0 100644 --- a/src/sudomock/__init__.py +++ b/src/sudomock/__init__.py @@ -25,8 +25,7 @@ from __future__ import annotations -import importlib.metadata - +from ._version import __version__ from .async_client import AsyncSudoMock from .client import SudoMock from .exceptions import ( @@ -85,11 +84,6 @@ ) from .webhooks import verify_webhook_signature -try: - __version__ = importlib.metadata.version("sudomock") -except importlib.metadata.PackageNotFoundError: - __version__ = "0.0.0-dev" - __all__ = [ # Clients "SudoMock", diff --git a/src/sudomock/_http.py b/src/sudomock/_http.py index 6c3831e..5f8c5a6 100644 --- a/src/sudomock/_http.py +++ b/src/sudomock/_http.py @@ -4,6 +4,7 @@ ``httpx.Client`` / ``httpx.AsyncClient`` with: * ``x-api-key`` authentication header +* ``X-SudoMock-Client`` / ``User-Agent`` client identity (``python-sdk/``) * Automatic error → exception mapping * Retry with exponential backoff for 429 / 5xx via *tenacity* * Configurable timeouts (default vs. render) @@ -11,7 +12,6 @@ from __future__ import annotations -import importlib.metadata from typing import Any, Optional import httpx @@ -23,6 +23,7 @@ ) from ._public_contract import public_error_code, public_error_text +from ._version import __version__ from .exceptions import ( AuthenticationError, InsufficientCreditsError, @@ -33,12 +34,10 @@ ValidationError, ) -try: - _SDK_VERSION = importlib.metadata.version("sudomock") -except importlib.metadata.PackageNotFoundError: - _SDK_VERSION = "0.0.0-dev" - -_USER_AGENT = f"sudomock-python/{_SDK_VERSION}" +# How this SDK introduces itself on every request. The same value goes out as +# ``X-SudoMock-Client`` and as ``User-Agent`` so the API can tell which client +# and which release made a call. +CLIENT_ID = f"python-sdk/{__version__}" DEFAULT_BASE_URL = "https://api.sudomock.com" DEFAULT_TIMEOUT = 30.0 @@ -160,7 +159,8 @@ def __init__( base_url=self._base_url, headers={ "x-api-key": self._api_key, - "user-agent": _USER_AGENT, + "x-sudomock-client": CLIENT_ID, + "user-agent": CLIENT_ID, "accept": "application/json", }, timeout=httpx.Timeout(self._timeout), @@ -235,7 +235,8 @@ def __init__( base_url=self._base_url, headers={ "x-api-key": self._api_key, - "user-agent": _USER_AGENT, + "x-sudomock-client": CLIENT_ID, + "user-agent": CLIENT_ID, "accept": "application/json", }, timeout=httpx.Timeout(self._timeout), diff --git a/src/sudomock/_public_contract.py b/src/sudomock/_public_contract.py index 36a579a..925985a 100644 --- a/src/sudomock/_public_contract.py +++ b/src/sudomock/_public_contract.py @@ -10,7 +10,9 @@ # out, the list names every provider and pipeline concept it exists to hide, so # the scrubber becomes the leak. Decoding at import keeps behaviour identical. _ENGINE_DETAIL = re.compile( - base64.b64decode("Z2VtaW5pfGFkdmFuY2VkLj9tb2RlbHxcYm1vZGVsXGJ8cHJvbXB0fG1hc2soPzpffC18XGIpfHNlZ21lbnQoPzphdGlvbik/KD86X3wtfFxiKXxyZWdpb24uP2luZGV4fGRlcHRofGRpc3BsYWNlbWVudHxncmlkfHdhcnB8c2hhZGluZ3xwcm92aWRlcnxwaXBlbGluZXxlbmdpbmV8aW50ZXJuYWx8cHJpdmF0ZXxzdG9yYWdlfGJ1Y2tldHxjb25maWcuP3ZlcnNpb258c2V0dXAuP3JldmlzaW9ufGVkaXQuP2dlbmVyYXRpb258XGJwaGFzZVxifHN0YXRlLj9tYWNoaW5lfCg/OmludGVybmFsfHByb2Nlc3Npbmd8d29ya2Zsb3cpLj9zdGF0ZQ==").decode(), + base64.b64decode( + "Z2VtaW5pfGFkdmFuY2VkLj9tb2RlbHxcYm1vZGVsXGJ8cHJvbXB0fG1hc2soPzpffC18XGIpfHNlZ21lbnQoPzphdGlvbik/KD86X3wtfFxiKXxyZWdpb24uP2luZGV4fGRlcHRofGRpc3BsYWNlbWVudHxncmlkfHdhcnB8c2hhZGluZ3xwcm92aWRlcnxwaXBlbGluZXxlbmdpbmV8aW50ZXJuYWx8cHJpdmF0ZXxzdG9yYWdlfGJ1Y2tldHxjb25maWcuP3ZlcnNpb258c2V0dXAuP3JldmlzaW9ufGVkaXQuP2dlbmVyYXRpb258XGJwaGFzZVxifHN0YXRlLj9tYWNoaW5lfCg/OmludGVybmFsfHByb2Nlc3Npbmd8d29ya2Zsb3cpLj9zdGF0ZQ==" + ).decode(), re.IGNORECASE, ) _PRIVATE_KEY = re.compile( diff --git a/src/sudomock/_version.py b/src/sudomock/_version.py new file mode 100644 index 0000000..3c2314f --- /dev/null +++ b/src/sudomock/_version.py @@ -0,0 +1,15 @@ +"""Single source of the installed package version. + +Read once from the distribution metadata (``pyproject.toml`` ``version``) so +``sudomock.__version__`` and the identity the SDK sends on every request can +never disagree. +""" + +from __future__ import annotations + +import importlib.metadata + +try: + __version__ = importlib.metadata.version("sudomock") +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0-dev" diff --git a/tests/test_async_client.py b/tests/test_async_client.py index d89cc17..2e00639 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -9,6 +9,7 @@ import httpx import pytest +import sudomock from sudomock import AsyncSudoMock from sudomock.exceptions import ( AuthenticationError, @@ -760,17 +761,19 @@ async def test_no_retry_on_4xx(self, mock_api: respx.MockRouter) -> None: # --------------------------------------------------------------------------- -# User-Agent header +# Client identity headers # --------------------------------------------------------------------------- -class TestAsyncUserAgent: - async def test_user_agent_header(self, mock_api: respx.MockRouter) -> None: +class TestAsyncClientIdentity: + async def test_client_identity_headers(self, mock_api: respx.MockRouter) -> None: route = mock_api.get("/api/v1/mockups").mock( return_value=httpx.Response(200, json=MOCK_MOCKUP_LIST_RESPONSE) ) async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: await client.mockups.list() - ua = route.calls.last.request.headers["user-agent"] - assert ua.startswith("sudomock-python/") + headers = route.calls.last.request.headers + expected = f"python-sdk/{sudomock.__version__}" + assert headers["x-sudomock-client"] == expected + assert headers["user-agent"] == expected diff --git a/tests/test_client.py b/tests/test_client.py index 72f8624..7162483 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -9,6 +9,7 @@ import httpx import pytest +import sudomock from sudomock import SudoMock from sudomock.exceptions import ( AuthenticationError, @@ -1014,17 +1015,35 @@ def test_retries_exhausted(self, mock_api: respx.MockRouter) -> None: # --------------------------------------------------------------------------- -# User-Agent header +# Client identity headers # --------------------------------------------------------------------------- -class TestUserAgent: - def test_user_agent_header(self, mock_api: respx.MockRouter) -> None: +class TestClientIdentity: + def test_client_identity_headers(self, mock_api: respx.MockRouter) -> None: route = mock_api.get("/api/v1/mockups").mock( return_value=httpx.Response(200, json=MOCK_MOCKUP_LIST_RESPONSE) ) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: client.mockups.list() - ua = route.calls.last.request.headers["user-agent"] - assert ua.startswith("sudomock-python/") + headers = route.calls.last.request.headers + expected = f"python-sdk/{sudomock.__version__}" + assert headers["x-sudomock-client"] == expected + assert headers["user-agent"] == expected + assert "\n" not in expected and "\r" not in expected + + def test_client_identity_survives_per_request_headers(self, mock_api: respx.MockRouter) -> None: + """A per-request header (e.g. Idempotency-Key) must not drop the identity.""" + route = mock_api.post("/api/v1/sudoai/2d-mockups").mock( + return_value=httpx.Response(201, json=MOCK_2D_MOCKUP_GET_RESPONSE) + ) + with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + client.ai.create( + source_url="https://example.com/product.jpg", + idempotency_key="idem-123", + ) + + headers = route.calls.last.request.headers + assert headers["idempotency-key"] == "idem-123" + assert headers["x-sudomock-client"] == f"python-sdk/{sudomock.__version__}"