From ce3ba0f11acb317f5e7d0491eb8a423411e4f5e2 Mon Sep 17 00:00:00 2001 From: SudoMock Labs Date: Fri, 18 Sep 2026 22:47:47 +0300 Subject: [PATCH 1/2] =?UTF-8?q?Foto=20mockup=20aile=20kind'lar=C4=B1:=20Jo?= =?UTF-8?q?bKind=20Literal=20ve=20wait=5Ffor=5F2d=5Fmockup=20iki=20yaz?= =?UTF-8?q?=C4=B1m=C4=B1=20da=20kabul=20eder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - models.py: JobKind Literal (render, video, upload, 2d_create, 2d_render, photo_mockup_create, photo_mockup_render) ve PHOTO_MOCKUP_CREATE_KINDS / PHOTO_MOCKUP_RENDER_KINDS çiftleri. Job.kind ve JobAccepted.kind ileri uyumluluk için düz string kalır. - client.py / async_client.py: wait_for_2d_mockup kind kapısı tek literal yerine aile çiftini sorar; hata mesajı iki yazımı da sayar. jobs.list(kind=) JobKind ile tiplenir, docstring yeni kind'ları listeler. - __init__.py: JobKind export'u. - Testler: aile kind'lı job kabulü (sync + async), JobKind sözleşmesi ve bilinmeyen kind'ın parse edilmeye devam ettiği pini. - README ve CHANGELOG [Unreleased] güncellendi. --- CHANGELOG.md | 19 +++++++++++++++++++ README.md | 4 ++-- src/sudomock/__init__.py | 2 ++ src/sudomock/async_client.py | 18 +++++++++++++----- src/sudomock/client.py | 18 +++++++++++++----- src/sudomock/models.py | 27 ++++++++++++++++++++++++++- tests/test_async_client.py | 24 +++++++++++++++++++++++- tests/test_client.py | 22 +++++++++++++++++++++- tests/test_models.py | 35 +++++++++++++++++++++++++++++++++++ 9 files changed, 154 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec133fc..b61ce4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `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 diff --git a/README.md b/README.md index 861eec0..f80c4a5 100644 --- a/README.md +++ b/README.md @@ -490,7 +490,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 | @@ -505,7 +505,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 | diff --git a/src/sudomock/__init__.py b/src/sudomock/__init__.py index 2eb80b0..3d79842 100644 --- a/src/sudomock/__init__.py +++ b/src/sudomock/__init__.py @@ -50,6 +50,7 @@ FullSurface, Job, JobAccepted, + JobKind, JobList, Mockup, MockupList, @@ -98,6 +99,7 @@ "FullSurface", "Job", "JobAccepted", + "JobKind", "JobList", "Mockup", "MockupList", diff --git a/src/sudomock/async_client.py b/src/sudomock/async_client.py index 64fcedb..5cb1a79 100644 --- a/src/sudomock/async_client.py +++ b/src/sudomock/async_client.py @@ -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, @@ -319,7 +321,7 @@ 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, @@ -327,8 +329,10 @@ async def list( """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``. @@ -667,8 +671,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( diff --git a/src/sudomock/client.py b/src/sudomock/client.py index f1d75b8..eb84c0f 100644 --- a/src/sudomock/client.py +++ b/src/sudomock/client.py @@ -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, @@ -328,7 +330,7 @@ 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, @@ -336,8 +338,10 @@ def list( """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``. @@ -706,8 +710,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( diff --git a/src/sudomock/models.py b/src/sudomock/models.py index 83fc5cc..2e6bc15 100644 --- a/src/sudomock/models.py +++ b/src/sudomock/models.py @@ -478,6 +478,27 @@ class BackgroundRemoval(_Outcome): # Async jobs (is_async renders / uploads / video) # --------------------------------------------------------------------------- +# Every ``jobs.kind`` value the API admits, in the order the API lists them. +# A photo mockup has two spellings of its kind: the published one +# (``2d_create`` / ``2d_render``) and the family's own name +# (``photo_mockup_create`` / ``photo_mockup_render``). Both name the same work +# and both can come back from ``GET /api/v1/jobs/{job_id}``; a filter on +# ``jobs.list(kind=...)`` with either spelling selects both. +JobKind = Literal[ + "render", + "video", + "upload", + "2d_create", + "2d_render", + "photo_mockup_create", + "photo_mockup_render", +] + +# The spellings that mean "create a photo mockup" / "render a photo mockup". +# A consumer that branches on the kind checks the pair, never one literal. +PHOTO_MOCKUP_CREATE_KINDS: tuple[str, ...] = ("2d_create", "photo_mockup_create") +PHOTO_MOCKUP_RENDER_KINDS: tuple[str, ...] = ("2d_render", "photo_mockup_render") + class JobAccepted(_Outcome): """Acknowledgement returned by a ``202 Accepted`` async submission. @@ -486,6 +507,9 @@ class JobAccepted(_Outcome): is_async=True)``, ``renders.create_video(...)``, and ``ai.create(..., is_async=True)``. Poll for completion with :meth:`jobs.get` or :meth:`jobs.wait` using :attr:`job_id`. + + :attr:`kind` is one of :data:`JobKind` today; it stays a plain string so a + kind this SDK does not know yet still parses instead of raising. """ job_id: str @@ -514,7 +538,8 @@ class Job(_Outcome): The terminal states are ``"succeeded"`` and ``"failed"``; ``"queued"`` and ``"running"`` are non-terminal. The current state value is exposed on - :attr:`status` (the API field is ``status``). + :attr:`status` (the API field is ``status``). :attr:`kind` is one of + :data:`JobKind` today and stays a plain string for forward compatibility. """ job_id: Optional[str] = None diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 2e00639..496653c 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -437,9 +437,31 @@ async def test_ai_wait_for_2d_mockup_rejects_wrong_job_kind( ) async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - with pytest.raises(SudoMockError, match="expected '2d_create'"): + with pytest.raises(SudoMockError, match="expected a photo-mockup create job"): await client.ai.wait_for_2d_mockup("video-job-001", poll_interval=0.0) + async def test_ai_wait_for_2d_mockup_accepts_family_kind( + self, mock_api: respx.MockRouter + ) -> None: + """A job stamped with the family kind is the same work as ``2d_create``.""" + job_route = mock_api.get("/api/v1/jobs/2d-create-job-001").mock( + return_value=httpx.Response( + 200, + json={**MOCK_2D_MOCKUP_JOB_SUCCEEDED_RESPONSE, "kind": "photo_mockup_create"}, + ) + ) + detail_route = mock_api.get("/api/v1/sudoai/2d-mockups/2d-mockup-001").mock( + return_value=httpx.Response(200, json=MOCK_2D_MOCKUP_GET_RESPONSE) + ) + + async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + result = await client.ai.wait_for_2d_mockup("2d-create-job-001", poll_interval=0.0) + + assert isinstance(result, TwoDMockup) + assert result.mockup_id == "2d-mockup-001" + assert len(job_route.calls) == 1 + assert len(detail_route.calls) == 1 + async def test_ai_wait_for_2d_mockup_missing_mockup_uuid( self, mock_api: respx.MockRouter ) -> None: diff --git a/tests/test_client.py b/tests/test_client.py index 7162483..ffd13fe 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -562,9 +562,29 @@ def test_ai_wait_for_2d_mockup_rejects_wrong_job_kind(self, mock_api: respx.Mock ) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - with pytest.raises(SudoMockError, match="expected '2d_create'"): + with pytest.raises(SudoMockError, match="expected a photo-mockup create job"): client.ai.wait_for_2d_mockup("video-job-001", poll_interval=0.0) + def test_ai_wait_for_2d_mockup_accepts_family_kind(self, mock_api: respx.MockRouter) -> None: + """A job stamped with the family kind is the same work as ``2d_create``.""" + job_route = mock_api.get("/api/v1/jobs/2d-create-job-001").mock( + return_value=httpx.Response( + 200, + json={**MOCK_2D_MOCKUP_JOB_SUCCEEDED_RESPONSE, "kind": "photo_mockup_create"}, + ) + ) + detail_route = mock_api.get("/api/v1/sudoai/2d-mockups/2d-mockup-001").mock( + return_value=httpx.Response(200, json=MOCK_2D_MOCKUP_GET_RESPONSE) + ) + + with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + result = client.ai.wait_for_2d_mockup("2d-create-job-001", poll_interval=0.0) + + assert isinstance(result, TwoDMockup) + assert result.mockup_id == "2d-mockup-001" + assert len(job_route.calls) == 1 + assert len(detail_route.calls) == 1 + def test_ai_wait_for_2d_mockup_missing_mockup_uuid(self, mock_api: respx.MockRouter) -> None: mock_api.get("/api/v1/jobs/2d-create-job-001").mock( return_value=httpx.Response( diff --git a/tests/test_models.py b/tests/test_models.py index e93dd86..9624afa 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,10 +2,15 @@ from __future__ import annotations +from typing import get_args + import pytest from pydantic import ValidationError as PydanticValidationError +import sudomock from sudomock.models import ( + PHOTO_MOCKUP_CREATE_KINDS, + PHOTO_MOCKUP_RENDER_KINDS, Account, AccountInfo, AIRender, @@ -14,6 +19,7 @@ FullSurface, Job, JobAccepted, + JobKind, Mockup, MockupList, PrintFile, @@ -298,6 +304,35 @@ def test_url_empty_raises(self) -> None: _ = r.url +class TestJobKind: + def test_job_kind_lists_every_spelling_the_api_admits(self) -> None: + """``JobKind`` names every ``jobs.kind`` value, published and family, in API order.""" + assert get_args(JobKind) == ( + "render", + "video", + "upload", + "2d_create", + "2d_render", + "photo_mockup_create", + "photo_mockup_render", + ) + + def test_job_kind_is_exported_from_the_package(self) -> None: + assert "JobKind" in sudomock.__all__ + assert sudomock.JobKind is JobKind + + def test_photo_mockup_create_kinds_pair_both_spellings(self) -> None: + assert PHOTO_MOCKUP_CREATE_KINDS == ("2d_create", "photo_mockup_create") + assert PHOTO_MOCKUP_RENDER_KINDS == ("2d_render", "photo_mockup_render") + for kind in PHOTO_MOCKUP_CREATE_KINDS + PHOTO_MOCKUP_RENDER_KINDS: + assert kind in get_args(JobKind) + + def test_job_kind_is_not_a_response_gate(self) -> None: + """A kind this SDK does not know yet still parses: ``Job.kind`` stays a string.""" + job = Job(job_id="j-1", kind="some_future_kind", status="queued") + assert job.kind == "some_future_kind" + + class TestJobAccepted: def test_parse(self) -> None: j = JobAccepted( From eb38e4bf1d42824cb448c2351d22aa3bb51e4e77 Mon Sep 17 00:00:00 2001 From: SudoMock Labs Date: Fri, 18 Sep 2026 23:39:46 +0300 Subject: [PATCH 2/2] =?UTF-8?q?Webhook=20endpoint:=20event=5Fnaming=20pini?= =?UTF-8?q?=20se=C3=A7ilir,=20de=C4=9Fi=C5=9Ftirilir=20ve=20okunur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API bir webhook endpoint'ini fotoğraf-mockup olaylarının tek bir yazımına bağlar: 'current' (photo_mockup.*, photo_mockup_render.*) ya da 'legacy' (2d_mockup.*, 2d_render.*). SDK bu pini ne gönderebiliyor ne de yanıttan tipli okuyabiliyordu. - models.py: WebhookEventNaming Literal['legacy', 'current'] ve WebhookEndpoint.event_naming alanı. Alan Optional[str] kalır: pini olmayan eski dağıtımda None, ileride eklenecek bir yazım ValidationError yerine parse edilir (Job.kind ile aynı ilke). - client.py / async_client.py: webhook_endpoints.create ve update event_naming kabul eder. create'te verilmezse gövdeye yazılmaz, API varsayılanı 'current' geçerli olur; update'te verilmezse pin dokunulmaz. - __init__.py: WebhookEventNaming export'u. - Testler (RED->GREEN): create gövdesinde event_naming, verilmeyince alan yok, update gövdesi yalnız verilen alanları taşır, get/list yanıtında pin okunur, alanı olmayan eski yanıt None verir, Literal ve export pini; sync + asyncio. conftest fixture'ı API'nin döndürdüğü event_naming'i taşır. - README: webhook örneği ve referans tablosu event_naming'i anlatır. - Sürüm 0.9.1 -> 0.10.0; CHANGELOG [Unreleased] 2026-09-18 tarihli 0.10.0 başlığına döndü, karşılaştırma bağlantıları eklendi. --- CHANGELOG.md | 17 +++++++- README.md | 11 ++++- pyproject.toml | 2 +- src/sudomock/__init__.py | 2 + src/sudomock/async_client.py | 22 +++++++++- src/sudomock/client.py | 22 +++++++++- src/sudomock/models.py | 21 ++++++++- tests/conftest.py | 3 ++ tests/test_async_client_surface.py | 31 ++++++++++++++ tests/test_models.py | 17 ++++++++ tests/test_webhooks.py | 68 ++++++++++++++++++++++++++++++ 11 files changed, 208 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b61ce4e..12c9a2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,21 @@ 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 @@ -239,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 diff --git a/README.md b/README.md index f80c4a5..1d88faf 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 | diff --git a/pyproject.toml b/pyproject.toml index 9e28afb..5dde2d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/sudomock/__init__.py b/src/sudomock/__init__.py index 3d79842..c9987c9 100644 --- a/src/sudomock/__init__.py +++ b/src/sudomock/__init__.py @@ -81,6 +81,7 @@ WebhookDeliveryList, WebhookEndpoint, WebhookEndpointList, + WebhookEventNaming, WebhookSecret, ) from .webhooks import verify_webhook_signature @@ -130,6 +131,7 @@ "WebhookDeliveryList", "WebhookEndpoint", "WebhookEndpointList", + "WebhookEventNaming", "WebhookSecret", # Webhooks "verify_webhook_signature", diff --git a/src/sudomock/async_client.py b/src/sudomock/async_client.py index 5cb1a79..090d9fd 100644 --- a/src/sudomock/async_client.py +++ b/src/sudomock/async_client.py @@ -71,6 +71,7 @@ WebhookDeliveryList, WebhookEndpoint, WebhookEndpointList, + WebhookEventNaming, WebhookSecret, ) @@ -468,6 +469,7 @@ async def create( url: str, events: _StrList, description: Optional[str] = None, + event_naming: Optional[WebhookEventNaming] = None, ) -> WebhookEndpoint: """Register a new webhook endpoint. @@ -475,12 +477,20 @@ async def create( 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()) @@ -521,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 @@ -532,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 ) diff --git a/src/sudomock/client.py b/src/sudomock/client.py index eb84c0f..9077488 100644 --- a/src/sudomock/client.py +++ b/src/sudomock/client.py @@ -55,6 +55,7 @@ WebhookDeliveryList, WebhookEndpoint, WebhookEndpointList, + WebhookEventNaming, WebhookSecret, ) @@ -493,6 +494,7 @@ def create( url: str, events: _StrList, description: Optional[str] = None, + event_naming: Optional[WebhookEventNaming] = None, ) -> WebhookEndpoint: """Register a new webhook endpoint. @@ -501,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 @@ -511,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()) @@ -551,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 @@ -562,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()) diff --git a/src/sudomock/models.py b/src/sudomock/models.py index 2e6bc15..2cdb94d 100644 --- a/src/sudomock/models.py +++ b/src/sudomock/models.py @@ -653,13 +653,29 @@ class VideoOptions(_Outcome): # --------------------------------------------------------------------------- +# The two spellings of the photo-mockup events an endpoint can be pinned to: +# ``"current"`` delivers ``photo_mockup.*`` / ``photo_mockup_render.*`` and +# ``"legacy"`` delivers ``2d_mockup.*`` / ``2d_render.*``; the payload's +# ``kind`` follows the same pin. Every other event is spelled the same under +# both. The API pins a new endpoint to ``"current"`` unless told otherwise; +# an endpoint that predates the current names stays on ``"legacy"`` until it +# is re-pinned. +WebhookEventNaming = Literal["legacy", "current"] + + class WebhookEndpoint(_Base): """A registered outbound webhook endpoint. Mirrors the API's ``WebhookEndpointResponse``: the identifier is ``id``, - subscribed events are ``event_types`` (empty = subscribe to all), and the + subscribed events are ``event_types`` (empty = subscribe to all), the ``secret`` is masked (``whsec_****``) except on create / rotate - where the full value is returned once. + where the full value is returned once, and ``event_naming`` is the + spelling of the photo-mockup events this endpoint receives. + + :attr:`event_naming` is one of :data:`WebhookEventNaming` today; it stays + a plain string so a naming a later API release adds still parses instead + of raising ``ValidationError``. It is ``None`` on a deployment that + predates the field. """ id: str @@ -668,6 +684,7 @@ class WebhookEndpoint(_Base): description: Optional[str] = None event_types: list[str] = Field(default_factory=list) enabled: bool = True + event_naming: Optional[str] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None diff --git a/tests/conftest.py b/tests/conftest.py index 4a97950..e09e617 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -420,6 +420,9 @@ "description": None, "event_types": ["render.succeeded", "render.failed"], "enabled": True, + # The spelling of the photo-mockup events this endpoint is pinned to + # ('legacy' or 'current'); the API reports it on every endpoint response. + "event_naming": "current", "created_at": "2026-06-21T10:00:00Z", "updated_at": None, } diff --git a/tests/test_async_client_surface.py b/tests/test_async_client_surface.py index e990155..55b8c29 100644 --- a/tests/test_async_client_surface.py +++ b/tests/test_async_client_surface.py @@ -170,3 +170,34 @@ async def test_rotate_and_replay(self, mock_api: respx.MockRouter) -> None: await client.webhook_endpoints.replay_delivery("wh-1", "dlv-1") assert secret.secret == "whsec_new" assert len(route.calls) == 1 + + async def test_create_with_event_naming(self, mock_api: respx.MockRouter) -> None: + route = mock_api.post("/api/v1/webhook-endpoints").mock( + return_value=httpx.Response(201, json=MOCK_WEBHOOK_CREATE_RESPONSE) + ) + async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + result = await client.webhook_endpoints.create( + url="https://x.com/wh", events=["photo_mockup.ready"], event_naming="current" + ) + body = json.loads(route.calls.last.request.content) + assert body["event_naming"] == "current" + assert result.event_naming == "current" + + async def test_create_omits_event_naming_by_default(self, mock_api: respx.MockRouter) -> None: + route = mock_api.post("/api/v1/webhook-endpoints").mock( + return_value=httpx.Response(201, json=MOCK_WEBHOOK_CREATE_RESPONSE) + ) + async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + await client.webhook_endpoints.create(url="https://x.com/wh", events=[]) + body = json.loads(route.calls.last.request.content) + assert "event_naming" not in body + + async def test_update_event_naming(self, mock_api: respx.MockRouter) -> None: + route = mock_api.patch("/api/v1/webhook-endpoints/wh-1").mock( + return_value=httpx.Response(200, json=MOCK_WEBHOOK_CREATE_RESPONSE) + ) + async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + result = await client.webhook_endpoints.update("wh-1", event_naming="legacy") + body = json.loads(route.calls.last.request.content) + assert body == {"event_naming": "legacy"} + assert result.event_naming == "current" # what the API reported back diff --git a/tests/test_models.py b/tests/test_models.py index 9624afa..5b33427 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -35,6 +35,7 @@ VideoOptions, WebhookDelivery, WebhookEndpoint, + WebhookEventNaming, ) @@ -440,6 +441,22 @@ def test_endpoint(self) -> None: assert wh.id == "wh-1" assert wh.enabled is True assert wh.event_types == ["render.succeeded"] + # Absent on a deployment that predates the pin. + assert wh.event_naming is None + + def test_endpoint_event_naming_pin(self) -> None: + wh = WebhookEndpoint(id="wh-1", url="https://x.com/wh", event_naming="legacy") + assert wh.event_naming == "legacy" + + def test_endpoint_event_naming_stays_a_plain_string(self) -> None: + """A naming a later API release adds still parses instead of raising.""" + wh = WebhookEndpoint(id="wh-1", url="https://x.com/wh", event_naming="future") + assert wh.event_naming == "future" + + def test_event_naming_literal_and_export(self) -> None: + assert get_args(WebhookEventNaming) == ("legacy", "current") + assert "WebhookEventNaming" in sudomock.__all__ + assert sudomock.WebhookEventNaming is WebhookEventNaming def test_delivery(self) -> None: d = WebhookDelivery( diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 9c47b48..9a14295 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -128,6 +128,74 @@ def test_replay_delivery(self, mock_api: respx.MockRouter) -> None: assert len(route.calls) == 1 +class TestWebhookEndpointEventNaming: + """The endpoint's ``event_naming`` pin: chosen on create, changed on update, read back.""" + + def test_create_sends_event_naming_when_given(self, mock_api: respx.MockRouter) -> None: + route = mock_api.post("/api/v1/webhook-endpoints").mock( + return_value=httpx.Response(201, json=MOCK_WEBHOOK_CREATE_RESPONSE) + ) + with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + client.webhook_endpoints.create( + url="https://x.com/wh", events=["2d_mockup.ready"], event_naming="legacy" + ) + body = json.loads(route.calls.last.request.content) + assert body["event_naming"] == "legacy" + + def test_create_omits_event_naming_by_default(self, mock_api: respx.MockRouter) -> None: + """Left out, the field is not sent: the API pins a new endpoint to 'current'.""" + route = mock_api.post("/api/v1/webhook-endpoints").mock( + return_value=httpx.Response(201, json=MOCK_WEBHOOK_CREATE_RESPONSE) + ) + with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + client.webhook_endpoints.create(url="https://x.com/wh", events=[]) + body = json.loads(route.calls.last.request.content) + assert "event_naming" not in body + + def test_update_sends_event_naming(self, mock_api: respx.MockRouter) -> None: + route = mock_api.patch("/api/v1/webhook-endpoints/wh-uuid-1").mock( + return_value=httpx.Response(200, json=MOCK_WEBHOOK_GET_RESPONSE) + ) + with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + client.webhook_endpoints.update("wh-uuid-1", event_naming="current") + body = json.loads(route.calls.last.request.content) + assert body == {"event_naming": "current"} + + def test_update_without_event_naming_does_not_send_it(self, mock_api: respx.MockRouter) -> None: + route = mock_api.patch("/api/v1/webhook-endpoints/wh-uuid-1").mock( + return_value=httpx.Response(200, json=MOCK_WEBHOOK_GET_RESPONSE) + ) + with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + client.webhook_endpoints.update("wh-uuid-1", enabled=True) + body = json.loads(route.calls.last.request.content) + assert body == {"enabled": True} + + def test_endpoint_reports_its_event_naming_pin(self, mock_api: respx.MockRouter) -> None: + mock_api.get("/api/v1/webhook-endpoints/wh-uuid-1").mock( + return_value=httpx.Response(200, json=MOCK_WEBHOOK_GET_RESPONSE) + ) + mock_api.get("/api/v1/webhook-endpoints").mock( + return_value=httpx.Response(200, json=MOCK_WEBHOOK_LIST_RESPONSE) + ) + with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + single = client.webhook_endpoints.get("wh-uuid-1") + listed = client.webhook_endpoints.list() + assert single.event_naming == "current" + assert listed.webhook_endpoints[0].event_naming == "current" + + def test_endpoint_that_predates_the_pin_parses_without_it( + self, mock_api: respx.MockRouter + ) -> None: + """A deployment without the field still parses; the pin reads as ``None``.""" + older = {k: v for k, v in MOCK_WEBHOOK_GET_RESPONSE.items() if k != "event_naming"} + mock_api.get("/api/v1/webhook-endpoints/wh-uuid-1").mock( + return_value=httpx.Response(200, json=older) + ) + with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + result = client.webhook_endpoints.get("wh-uuid-1") + assert result.event_naming is None + + # --------------------------------------------------------------------------- # Signature verification # ---------------------------------------------------------------------------