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
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.11.1] - 2026-09-19

### Fixed
- **`client.ai` and `client.mockups` call the endpoints they have always
called.** In 0.11.0 both accessors were aliases of `client.photo_mockups` /
`client.psd_mockups` and so began calling `/api/v1/photo-mockups` and
`/api/v1/psd-mockups`. Code that upgraded the SDK without being rewritten
therefore opened its jobs on the current path and received the current job
kinds (`photo_mockup_create` / `photo_mockup_render`) where it had always
received `2d_create` / `2d_render` — a branch on `kind` stopped matching
without raising anything. `client.ai` is pinned back to
`/api/v1/sudoai/2d-mockups` and `client.mockups` to `/api/v1/mockups`, the
paths their callers were already using and that the API keeps serving, so
upgrading to this release changes nothing for code written against the
earlier names. `client.photo_mockups` and `client.psd_mockups` are unchanged
and keep calling the current paths; move an accessor over when you are ready
to read the current job kinds. The 0.11.0 note that an SDK had to stay pinned
below 0.11.0 to keep the earlier paths no longer applies.
- Assigning to `client.ai` or `client.mockups` reaches the earlier accessor, so
a test double injected under either name is the object that gets called.
- **Assigning to `client.ai` or `client.mockups` no longer moves
`client.photo_mockups` / `client.psd_mockups`.** The setter wrote both
accessors while the getter read only the earlier one, so the ordinary
save-and-restore pattern around a test double (`saved = client.ai` ...
`client.ai = saved`, or `mock.patch.object`) left the current accessor
pointing at the earlier endpoint for the rest of the process — every later
`client.photo_mockups` call went to `/api/v1/sudoai/2d-mockups` and came back
with the earlier job kinds, with nothing raised. Each earlier name now reads
back exactly what was written to it and leaves the current name alone, so a
restore puts both accessors where they started.
- **`del client.ai` and `del client.mockups` work.** The earlier names had no
deleter, so `mock.patch.object(client, "ai", double)` raised
`AttributeError: property 'ai' of 'SudoMock' object has no deleter` when its
block ended. Deleting an earlier name drops the override and restores the
accessor the client was built with, still pinned to the endpoint that name
has always called.

### Changed
- `webhook_endpoints.create(...)` docs: an endpoint that does not pass
`event_naming` is pinned by the spelling of its `events` — the earlier event
names pin it to `"legacy"`, the current names to `"current"`, an empty or
mixed list to `"legacy"`. The earlier note that the API always pinned a new
endpoint to `"current"` no longer describes what happens. Passing
`event_naming` explicitly decides it and is unaffected.


## [0.11.0] - 2026-09-19

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -543,7 +543,7 @@ client = SudoMock(
| Method | Description |
|--------|-------------|
| `client.webhook_endpoints.list()` | List registered endpoints |
| `client.webhook_endpoints.create(url=, events=, description=None, event_naming=None)` | Register an endpoint (empty `events` = all; `event_naming` `"current"` / `"legacy"`, API default `"current"`) |
| `client.webhook_endpoints.create(url=, events=, description=None, event_naming=None)` | Register an endpoint (empty `events` = all; `event_naming` `"current"` / `"legacy"`; left out, the API follows the spelling of `events`) |
| `client.webhook_endpoints.get(uuid)` | Get an endpoint |
| `client.webhook_endpoints.update(uuid, url=, events=, description=, enabled=, event_naming=)` | Update an endpoint (`event_naming` re-pins it to `"current"` or `"legacy"`) |
| `client.webhook_endpoints.delete(uuid)` | Delete an endpoint |
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.11.0"
version = "0.11.1"
description = "Official Python SDK for the SudoMock Mockup Generator API"
readme = "README.md"
license = "MIT"
Expand Down
10 changes: 10 additions & 0 deletions src/sudomock/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@
DEFAULT_RENDER_TIMEOUT = 120.0
DEFAULT_MAX_RETRIES = 3

# Endpoints the current accessors (``photo_mockups`` / ``psd_mockups``) call.
PHOTO_MOCKUPS_PATH = "/api/v1/photo-mockups"
PSD_MOCKUPS_PATH = "/api/v1/psd-mockups"

# Endpoints the earlier accessors (``ai`` / ``mockups``) have always called and
# keep calling. Code written against those names was never rewritten, so the
# request it produces must not change either. Both remain served by the API.
EARLIER_PHOTO_MOCKUPS_PATH = "/api/v1/sudoai/2d-mockups"
EARLIER_PSD_MOCKUPS_PATH = "/api/v1/mockups"


# ---------------------------------------------------------------------------
# Error mapping
Expand Down
113 changes: 88 additions & 25 deletions src/sudomock/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@
DEFAULT_MAX_RETRIES,
DEFAULT_RENDER_TIMEOUT,
DEFAULT_TIMEOUT,
EARLIER_PHOTO_MOCKUPS_PATH,
EARLIER_PSD_MOCKUPS_PATH,
PHOTO_MOCKUPS_PATH,
PSD_MOCKUPS_PATH,
AsyncTransport,
)
from ._public_contract import public_2d_render_targets
Expand Down Expand Up @@ -85,8 +89,9 @@
class _AsyncPsdMockupsResource:
"""Async PSD mockup template operations (list, get, update, delete)."""

def __init__(self, transport: AsyncTransport) -> None:
def __init__(self, transport: AsyncTransport, base: str = PSD_MOCKUPS_PATH) -> None:
self._transport = transport
self._base = base

async def list(
self,
Expand Down Expand Up @@ -116,7 +121,7 @@ async def list(
"""
resp = await self._transport.request(
"GET",
"/api/v1/psd-mockups",
self._base,
params={
"limit": limit,
"offset": offset,
Expand All @@ -142,7 +147,7 @@ async def get(self, uuid: str) -> Mockup:
Raises:
NotFoundError: If the mockup does not exist.
"""
resp = await self._transport.request("GET", f"/api/v1/psd-mockups/{uuid}")
resp = await self._transport.request("GET", f"{self._base}/{uuid}")
payload = resp.json()
return Mockup.model_validate({**payload["data"], "warnings": payload.get("warnings") or []})

Expand All @@ -159,9 +164,7 @@ async def update(self, uuid: str, *, name: str) -> Mockup:
Raises:
NotFoundError: If the mockup does not exist.
"""
resp = await self._transport.request(
"PATCH", f"/api/v1/psd-mockups/{uuid}", json={"name": name}
)
resp = await self._transport.request("PATCH", f"{self._base}/{uuid}", json={"name": name})
payload = resp.json()
return Mockup.model_validate({**payload["data"], "warnings": payload.get("warnings") or []})

Expand All @@ -174,7 +177,7 @@ async def delete(self, uuid: str) -> None:
Raises:
NotFoundError: If the mockup does not exist.
"""
await self._transport.request("DELETE", f"/api/v1/psd-mockups/{uuid}")
await self._transport.request("DELETE", f"{self._base}/{uuid}")


class _AsyncRendersResource:
Expand Down Expand Up @@ -481,9 +484,11 @@ async def create(
event_naming: Which spelling of the photo-mockup events this endpoint
receives: ``"current"`` (``photo_mockup.*``,
``photo_mockup_render.*``) or ``"legacy"`` (``2d_mockup.*``,
``2d_render.*``). Left out, the API pins a new endpoint to
``"current"``; pass ``"legacy"`` for a handler that still reads
the older names.
``2d_render.*``). Left out, the API reads the spelling of
``events``: a list written in the earlier names pins the
endpoint to ``"legacy"``, a list written in the current names
pins it to ``"current"``, and an empty or mixed list pins it to
``"legacy"``. Pass the value outright to decide it yourself.
"""
# API field is `event_types` (empty list = subscribe to all events).
# NOTE: the create endpoint has no `enabled` field (it is update-only).
Expand Down Expand Up @@ -601,8 +606,9 @@ async def replay_failed(self, uuid: str) -> None:
class _AsyncPhotoMockupsResource:
"""Async photo mockup operations (create, render, list, get, delete, print areas)."""

def __init__(self, transport: AsyncTransport) -> None:
def __init__(self, transport: AsyncTransport, base: str = PHOTO_MOCKUPS_PATH) -> None:
self._transport = transport
self._base = base

async def create(
self,
Expand Down Expand Up @@ -658,7 +664,7 @@ async def create(

resp = await self._transport.request(
"POST",
"/api/v1/photo-mockups",
self._base,
json=body,
headers={"Idempotency-Key": idempotency_key},
)
Expand Down Expand Up @@ -722,7 +728,7 @@ async def update_2d_print_areas(
"""
resp = await self._transport.request(
"PUT",
f"/api/v1/photo-mockups/{mockup_id}/print-areas",
f"{self._base}/{mockup_id}/print-areas",
json={"print_areas": print_areas},
)
return PhotoMockupPrintAreasUpdate.model_validate(resp.json()["data"])
Expand Down Expand Up @@ -793,7 +799,7 @@ async def render(

resp = await self._transport.request(
"POST",
f"/api/v1/photo-mockups/{mockup_uuid}/render",
f"{self._base}/{mockup_uuid}/render",
json=body,
timeout=self._transport._render_timeout,
)
Expand All @@ -814,7 +820,7 @@ async def list(
"""List your photo mockups (free; zero credits)."""
resp = await self._transport.request(
"GET",
"/api/v1/photo-mockups",
self._base,
params={
"limit": limit,
"offset": offset,
Expand All @@ -835,7 +841,7 @@ async def get(self, mockup_id: str) -> PhotoMockup:
Raises:
NotFoundError: If the photo mockup does not exist.
"""
resp = await self._transport.request("GET", f"/api/v1/photo-mockups/{mockup_id}")
resp = await self._transport.request("GET", f"{self._base}/{mockup_id}")
return PhotoMockup.model_validate(resp.json()["data"])

async def delete(self, mockup_id: str) -> None:
Expand All @@ -844,7 +850,7 @@ async def delete(self, mockup_id: str) -> None:
Raises:
NotFoundError: If the photo mockup does not exist.
"""
await self._transport.request("DELETE", f"/api/v1/photo-mockups/{mockup_id}")
await self._transport.request("DELETE", f"{self._base}/{mockup_id}")


class _AsyncImagesResource:
Expand Down Expand Up @@ -1077,35 +1083,92 @@ def __init__(
self.packages = _AsyncPackagesResource(self._transport)
self.webhook_endpoints = _AsyncWebhookEndpointsResource(self._transport)

# Same resources, pinned to the endpoints the earlier accessors have
# always called. Kept as their own objects so ``photo_mockups`` and
# ``psd_mockups`` can move on without moving the earlier callers.
self._earlier_photo_mockups = _AsyncPhotoMockupsResource(
self._transport, base=EARLIER_PHOTO_MOCKUPS_PATH
)
self._earlier_psd_mockups = _AsyncPsdMockupsResource(
self._transport, base=EARLIER_PSD_MOCKUPS_PATH
)

@property
def ai(self) -> _AsyncPhotoMockupsResource:
"""Earlier name of :attr:`photo_mockups`; emits a ``DeprecationWarning``."""
"""Earlier name of :attr:`photo_mockups`; emits a ``DeprecationWarning``.

Calls made through this name keep going to the endpoint they have
always gone to, so a job opened here is the job this caller has always
opened. Use :attr:`photo_mockups` for the current endpoint.
"""
warnings.warn(
"AsyncSudoMock.ai is deprecated; use AsyncSudoMock.photo_mockups instead.",
DeprecationWarning,
stacklevel=2,
)
return self.photo_mockups
return self._earlier_photo_mockups

@ai.setter
def ai(self, value: object) -> None:
"""Assignment still works so existing test doubles keep running."""
self.photo_mockups = value # type: ignore[assignment]
"""Assignment still works so existing test doubles keep running.

What is written here is what this name reads back, and only this name:
:attr:`photo_mockups` is left where it is. A caller that saves this
accessor, swaps in a double and writes the saved value back therefore
puts both accessors exactly where they started.
"""
self._earlier_photo_mockups = value # type: ignore[assignment]

@ai.deleter
def ai(self) -> None:
"""``del client.ai`` drops the override and restores the default.

The accessor goes back to the one the client was built with, still
pinned to the endpoint this name has always called.
``mock.patch.object`` deletes the attribute when its block ends, so
this is the way back from a patched double.
"""
self._earlier_photo_mockups = _AsyncPhotoMockupsResource(
self._transport, base=EARLIER_PHOTO_MOCKUPS_PATH
)

@property
def mockups(self) -> _AsyncPsdMockupsResource:
"""Earlier name of :attr:`psd_mockups`; emits a ``DeprecationWarning``."""
"""Earlier name of :attr:`psd_mockups`; emits a ``DeprecationWarning``.

Calls made through this name keep going to the endpoint they have
always gone to. Use :attr:`psd_mockups` for the current endpoint.
"""
warnings.warn(
"AsyncSudoMock.mockups is deprecated; use AsyncSudoMock.psd_mockups instead.",
DeprecationWarning,
stacklevel=2,
)
return self.psd_mockups
return self._earlier_psd_mockups

@mockups.setter
def mockups(self, value: object) -> None:
"""Assignment still works so existing test doubles keep running."""
self.psd_mockups = value # type: ignore[assignment]
"""Assignment still works so existing test doubles keep running.

What is written here is what this name reads back, and only this name:
:attr:`psd_mockups` is left where it is. A caller that saves this
accessor, swaps in a double and writes the saved value back therefore
puts both accessors exactly where they started.
"""
self._earlier_psd_mockups = value # type: ignore[assignment]

@mockups.deleter
def mockups(self) -> None:
"""``del client.mockups`` drops the override and restores the default.

The accessor goes back to the one the client was built with, still
pinned to the endpoint this name has always called.
``mock.patch.object`` deletes the attribute when its block ends, so
this is the way back from a patched double.
"""
self._earlier_psd_mockups = _AsyncPsdMockupsResource(
self._transport, base=EARLIER_PSD_MOCKUPS_PATH
)

async def close(self) -> None:
"""Close the underlying HTTP connection pool."""
Expand Down
Loading
Loading