Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>`, and the
`User-Agent` header sends the same value (it used to read
`sudomock-python/<version>`). 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`
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 1 addition & 7 deletions src/sudomock/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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",
Expand Down
19 changes: 10 additions & 9 deletions src/sudomock/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
``httpx.Client`` / ``httpx.AsyncClient`` with:

* ``x-api-key`` authentication header
* ``X-SudoMock-Client`` / ``User-Agent`` client identity (``python-sdk/<version>``)
* Automatic error → exception mapping
* Retry with exponential backoff for 429 / 5xx via *tenacity*
* Configurable timeouts (default vs. render)
"""

from __future__ import annotations

import importlib.metadata
from typing import Any, Optional

import httpx
Expand All @@ -23,6 +23,7 @@
)

from ._public_contract import public_error_code, public_error_text
from ._version import __version__
from .exceptions import (
AuthenticationError,
InsufficientCreditsError,
Expand All @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
4 changes: 3 additions & 1 deletion src/sudomock/_public_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
15 changes: 15 additions & 0 deletions src/sudomock/_version.py
Original file line number Diff line number Diff line change
@@ -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"
13 changes: 8 additions & 5 deletions tests/test_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import httpx
import pytest

import sudomock
from sudomock import AsyncSudoMock
from sudomock.exceptions import (
AuthenticationError,
Expand Down Expand Up @@ -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
29 changes: 24 additions & 5 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import httpx
import pytest

import sudomock
from sudomock import SudoMock
from sudomock.exceptions import (
AuthenticationError,
Expand Down Expand Up @@ -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__}"
Loading