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

## [Unreleased]

## [0.10.0] - 2026-09-18

### Added
- `WebhookEndpoint.event_naming`, the spelling of the photo-mockup events an
endpoint is pinned to: `"current"` (`photo_mockup.*`,
`photo_mockup_render.*`) or `"legacy"` (`2d_mockup.*`, `2d_render.*`). The
payload's `kind` follows the same pin. The field carries a default of `None`,
so a client kept working against a deployment that predates it, and it stays
a plain string so a naming a later API release adds still parses.
- `client.webhook_endpoints.create(..., event_naming=)` and
`client.webhook_endpoints.update(uuid, event_naming=)` choose or change that
pin, on both the sync and the asyncio client. Left out on create, the field
is not sent and the API pins a new endpoint to `"current"`; left out on
update, the pin is untouched. `WebhookEventNaming`, the `Literal` of the two
values, is exported from the package.
- `JobKind`, a `Literal` of every `jobs.kind` value the API admits:
`render`, `video`, `upload`, `2d_create`, `2d_render`,
`photo_mockup_create` and `photo_mockup_render`. It is exported from the
package and types the `kind` filter of `client.jobs.list(...)`. A photo
mockup has two spellings of its kind, the published one (`2d_*`) and the
family's own name (`photo_mockup_*`); both name the same work, and a filter
on either spelling selects both.

### Changed
- `client.ai.wait_for_2d_mockup(...)` accepts a job of kind
`photo_mockup_create` as well as `2d_create`. A job of any other kind still
raises `SudoMockError`; the message now reads
`expected a photo-mockup create job ('2d_create' or 'photo_mockup_create')`
instead of `expected '2d_create'`.

`Job.kind` and `JobAccepted.kind` stay plain strings on purpose, so a kind a
later API release adds still parses instead of raising `ValidationError`.

## [0.9.1] - 2026-09-18

### Added
Expand Down Expand Up @@ -220,7 +253,8 @@ parses is still present and still required.
- Typed Pydantic v2 response models, typed exceptions, and tenacity-backed retry
with exponential backoff.

[Unreleased]: https://github.com/sudomock/sudomock-python/compare/v0.7.0...HEAD
[Unreleased]: https://github.com/sudomock/sudomock-python/compare/v0.10.0...HEAD
[0.10.0]: https://github.com/sudomock/sudomock-python/compare/v0.9.1...v0.10.0
[0.7.0]: https://github.com/sudomock/sudomock-python/compare/v0.6.1...v0.7.0
[0.2.0]: https://github.com/sudomock/sudomock-python/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/sudomock/sudomock-python/releases/tag/v0.1.0
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,13 @@ ep = client.webhook_endpoints.create(
)
print(ep.secret) # store this -- it signs deliveries

# Photo-mockup events reach an endpoint in one spelling, its `event_naming`
# pin: "current" (photo_mockup.*, photo_mockup_render.*) or "legacy"
# (2d_mockup.*, 2d_render.*). A new endpoint is pinned to "current"; pass
# "legacy" for a handler that still reads the older names, and re-pin later.
print(ep.event_naming) # "current"
client.webhook_endpoints.update(ep.id, event_naming="legacy")

# List / update / rotate / test / replay
client.webhook_endpoints.list()
client.webhook_endpoints.update(ep.id, enabled=False)
Expand Down Expand Up @@ -490,7 +497,7 @@ client = SudoMock(

| Method | Description |
|--------|-------------|
| `client.jobs.list(kind=, mockup_uuid=, limit=, cursor=)` | List your async jobs (keyset-paginated, newest first) |
| `client.jobs.list(kind=, mockup_uuid=, limit=, cursor=)` | List your async jobs (keyset-paginated, newest first). `kind` is a `JobKind`: `render`, `video`, `upload`, `2d_create`, `2d_render`, `photo_mockup_create`, `photo_mockup_render`; a photo-mockup kind selects both spellings of that job |
| `client.jobs.get(job_id)` | Get async job status (`queued`/`running`/`succeeded`/`failed`) |
| `client.jobs.wait(job_id, poll_interval=2.0, timeout=300.0)` | Poll until the job reaches a terminal state |

Expand All @@ -505,7 +512,7 @@ client = SudoMock(
| Method | Description |
|--------|-------------|
| `client.ai.create(source_url=, source_base64=, name=, print_areas=, is_async=False, idempotency_key=)` | Create a 2D mockup (25 credits; sync `TwoDMockup` by default, or `JobAccepted` when `is_async=True`) |
| `client.ai.wait_for_2d_mockup(job_id, poll_interval=2.0, timeout=180.0)` | Wait for an `is_async=True` creation and return the full 2D mockup |
| `client.ai.wait_for_2d_mockup(job_id, poll_interval=2.0, timeout=180.0)` | Wait for an `is_async=True` creation and return the full 2D mockup (accepts a job of kind `2d_create` or `photo_mockup_create`) |
| `client.ai.update_2d_print_areas(mockup_id, print_areas)` | Replace a 2D mockup's print areas (free) |
| `client.ai.render(mockup_uuid=, print_areas=, export_options=, is_async=False)` | Render artwork onto a 2D mockup (5 credits; sync `AIRender` with `render_uuid` by default, or `JobAccepted` when `is_async=True`) |
| `client.ai.list(limit=, offset=, customizable_only=)` | List your 2D mockups; set `customizable_only=True` for shopper-ready items |
Expand Down Expand Up @@ -536,9 +543,9 @@ client = SudoMock(
| Method | Description |
|--------|-------------|
| `client.webhook_endpoints.list()` | List registered endpoints |
| `client.webhook_endpoints.create(url=, events=, description=None)` | Register an endpoint (empty `events` = all) |
| `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.get(uuid)` | Get an endpoint |
| `client.webhook_endpoints.update(uuid, url=, events=, description=, enabled=)` | Update 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 |
| `client.webhook_endpoints.rotate_secret(uuid)` | Rotate the signing secret |
| `client.webhook_endpoints.test(uuid)` | Send a synthetic test delivery |
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.1"
version = "0.10.0"
description = "Official Python SDK for the SudoMock Mockup Generator API"
readme = "README.md"
license = "MIT"
Expand Down
4 changes: 4 additions & 0 deletions src/sudomock/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
FullSurface,
Job,
JobAccepted,
JobKind,
JobList,
Mockup,
MockupList,
Expand Down Expand Up @@ -80,6 +81,7 @@
WebhookDeliveryList,
WebhookEndpoint,
WebhookEndpointList,
WebhookEventNaming,
WebhookSecret,
)
from .webhooks import verify_webhook_signature
Expand All @@ -98,6 +100,7 @@
"FullSurface",
"Job",
"JobAccepted",
"JobKind",
"JobList",
"Mockup",
"MockupList",
Expand Down Expand Up @@ -128,6 +131,7 @@
"WebhookDeliveryList",
"WebhookEndpoint",
"WebhookEndpointList",
"WebhookEventNaming",
"WebhookSecret",
# Webhooks
"verify_webhook_signature",
Expand Down
40 changes: 34 additions & 6 deletions src/sudomock/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,13 @@
from ._public_contract import public_2d_render_targets
from .exceptions import JobFailedError, JobTimeoutError, SudoMockError
from .models import (
PHOTO_MOCKUP_CREATE_KINDS,
AccountInfo,
AIRender,
BackgroundRemoval,
Job,
JobAccepted,
JobKind,
JobList,
Mockup,
MockupList,
Expand All @@ -69,6 +71,7 @@
WebhookDeliveryList,
WebhookEndpoint,
WebhookEndpointList,
WebhookEventNaming,
WebhookSecret,
)

Expand Down Expand Up @@ -319,16 +322,18 @@ def __init__(self, transport: AsyncTransport) -> None:
async def list(
self,
*,
kind: Optional[str] = None,
kind: Optional[JobKind] = None,
mockup_uuid: Optional[str] = None,
limit: Optional[int] = None,
cursor: Optional[str] = None,
) -> JobList:
"""List your async jobs, newest first (keyset-paginated).

Args:
kind: Filter by job kind: ``video``, ``render``, ``upload``, or
``2d_create``.
kind: Filter by job kind: ``video``, ``render``, ``upload``,
``2d_create``, ``2d_render``, ``photo_mockup_create`` or
``photo_mockup_render``. A photo-mockup kind selects both
spellings of that job.
mockup_uuid: Filter by source mockup (raw-image videos excluded).
limit: Page size, 1..50 (default server-side: 20).
cursor: Opaque keyset cursor from a previous page's ``next_cursor``.
Expand Down Expand Up @@ -464,19 +469,28 @@ async def create(
url: str,
events: _StrList,
description: Optional[str] = None,
event_naming: Optional[WebhookEventNaming] = None,
) -> WebhookEndpoint:
"""Register a new webhook endpoint.

Args:
url: HTTPS URL that will receive POSTed events.
events: Event types to subscribe to; an empty list subscribes to ALL.
description: Optional human-readable label (≤255 chars).
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.
"""
# API field is `event_types` (empty list = subscribe to all events).
# NOTE: the create endpoint has no `enabled` field (it is update-only).
body: dict[str, Any] = {"url": url, "event_types": events}
if description is not None:
body["description"] = description
if event_naming is not None:
body["event_naming"] = event_naming
resp = await self._transport.request("POST", "/api/v1/webhook-endpoints", json=body)
# BARE endpoint object (no {success, data} envelope).
return WebhookEndpoint.model_validate(resp.json())
Expand Down Expand Up @@ -517,8 +531,16 @@ async def update(
events: Optional[_StrList] = None,
description: Optional[str] = None,
enabled: Optional[bool] = None,
event_naming: Optional[WebhookEventNaming] = None,
) -> WebhookEndpoint:
"""Update a webhook endpoint's URL, events, description, or enabled state."""
"""Update a webhook endpoint's URL, events, description, enabled state
or event naming.

Args:
event_naming: Re-pin the endpoint to ``"current"`` or ``"legacy"``
event names once its handler is ready for them. Only the fields
passed are sent.
"""
body: dict[str, Any] = {}
if url is not None:
body["url"] = url
Expand All @@ -528,6 +550,8 @@ async def update(
body["description"] = description
if enabled is not None:
body["enabled"] = enabled
if event_naming is not None:
body["event_naming"] = event_naming
resp = await self._transport.request(
"PATCH", f"/api/v1/webhook-endpoints/{uuid}", json=body
)
Expand Down Expand Up @@ -667,8 +691,12 @@ async def wait_for_2d_mockup(
except TimeoutError as exc:
raise JobTimeoutError(job_id, timeout=timeout) from exc

if job.kind not in (None, "2d_create"):
raise SudoMockError(f"Job {job_id} has kind {job.kind!r}; expected '2d_create'")
if job.kind is not None and job.kind not in PHOTO_MOCKUP_CREATE_KINDS:
expected = " or ".join(repr(kind) for kind in PHOTO_MOCKUP_CREATE_KINDS)
raise SudoMockError(
f"Job {job_id} has kind {job.kind!r}; "
f"expected a photo-mockup create job ({expected})"
)
if job.failed:
error_code, reason = job.failure_details()
raise JobFailedError(
Expand Down
40 changes: 34 additions & 6 deletions src/sudomock/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@
from ._public_contract import public_2d_render_targets
from .exceptions import JobFailedError, JobTimeoutError, SudoMockError
from .models import (
PHOTO_MOCKUP_CREATE_KINDS,
AccountInfo,
AIRender,
BackgroundRemoval,
Job,
JobAccepted,
JobKind,
JobList,
Mockup,
MockupList,
Expand All @@ -53,6 +55,7 @@
WebhookDeliveryList,
WebhookEndpoint,
WebhookEndpointList,
WebhookEventNaming,
WebhookSecret,
)

Expand Down Expand Up @@ -328,16 +331,18 @@ def __init__(self, transport: SyncTransport) -> None:
def list(
self,
*,
kind: Optional[str] = None,
kind: Optional[JobKind] = None,
mockup_uuid: Optional[str] = None,
limit: Optional[int] = None,
cursor: Optional[str] = None,
) -> JobList:
"""List your async jobs, newest first (keyset-paginated).

Args:
kind: Filter by job kind: ``video``, ``render``, ``upload``, or
``2d_create``.
kind: Filter by job kind: ``video``, ``render``, ``upload``,
``2d_create``, ``2d_render``, ``photo_mockup_create`` or
``photo_mockup_render``. A photo-mockup kind selects both
spellings of that job.
mockup_uuid: Filter by source mockup (raw-image videos excluded).
limit: Page size, 1..50 (default server-side: 20).
cursor: Opaque keyset cursor from a previous page's ``next_cursor``.
Expand Down Expand Up @@ -489,6 +494,7 @@ def create(
url: str,
events: _StrList,
description: Optional[str] = None,
event_naming: Optional[WebhookEventNaming] = None,
) -> WebhookEndpoint:
"""Register a new webhook endpoint.

Expand All @@ -497,6 +503,12 @@ def create(
events: Event types to subscribe to (e.g. ``["render.succeeded"]``);
an empty list subscribes to ALL events.
description: Optional human-readable label (≤255 chars).
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.

Returns:
The created :class:`WebhookEndpoint` (includes the signing
Expand All @@ -507,6 +519,8 @@ def create(
body: dict[str, Any] = {"url": url, "event_types": events}
if description is not None:
body["description"] = description
if event_naming is not None:
body["event_naming"] = event_naming
resp = self._transport.request("POST", "/api/v1/webhook-endpoints", json=body)
# BARE endpoint object (no {success, data} envelope).
return WebhookEndpoint.model_validate(resp.json())
Expand Down Expand Up @@ -547,8 +561,16 @@ def update(
events: Optional[_StrList] = None,
description: Optional[str] = None,
enabled: Optional[bool] = None,
event_naming: Optional[WebhookEventNaming] = None,
) -> WebhookEndpoint:
"""Update a webhook endpoint's URL, events, description, or enabled state."""
"""Update a webhook endpoint's URL, events, description, enabled state
or event naming.

Args:
event_naming: Re-pin the endpoint to ``"current"`` or ``"legacy"``
event names once its handler is ready for them. Only the fields
passed are sent.
"""
body: dict[str, Any] = {}
if url is not None:
body["url"] = url
Expand All @@ -558,6 +580,8 @@ def update(
body["description"] = description
if enabled is not None:
body["enabled"] = enabled
if event_naming is not None:
body["event_naming"] = event_naming
resp = self._transport.request("PATCH", f"/api/v1/webhook-endpoints/{uuid}", json=body)
# BARE endpoint object (no {success, data} envelope).
return WebhookEndpoint.model_validate(resp.json())
Expand Down Expand Up @@ -706,8 +730,12 @@ def wait_for_2d_mockup(
except TimeoutError as exc:
raise JobTimeoutError(job_id, timeout=timeout) from exc

if job.kind not in (None, "2d_create"):
raise SudoMockError(f"Job {job_id} has kind {job.kind!r}; expected '2d_create'")
if job.kind is not None and job.kind not in PHOTO_MOCKUP_CREATE_KINDS:
expected = " or ".join(repr(kind) for kind in PHOTO_MOCKUP_CREATE_KINDS)
raise SudoMockError(
f"Job {job_id} has kind {job.kind!r}; "
f"expected a photo-mockup create job ({expected})"
)
if job.failed:
error_code, reason = job.failure_details()
raise JobFailedError(
Expand Down
Loading
Loading