diff --git a/CHANGELOG.md b/CHANGELOG.md index 12c9a2e..a82c76d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.11.0] - 2026-09-19 + +### Added +- `client.photo_mockups`, the photo mockup resource (`create`, + `wait_for_2d_mockup`, `update_2d_print_areas`, `render`, `list`, `get`, + `delete`), and `client.psd_mockups`, the PSD mockup template resource + (`list`, `get`, `update`, `delete`), on both the sync and the asyncio client. + Method names and signatures are unchanged. +- `PhotoMockup`, `PhotoMockupList`, `PhotoMockupPrintAreasUpdate` and + `PhotoMockupRender`, exported from the package. Each is the same class as + `TwoDMockup`, `TwoDMockupList`, `TwoDPrintAreasUpdate` and `AIRender`, so an + `isinstance` check written against either name keeps passing. + +### Changed +- The photo mockup resource now calls `/api/v1/photo-mockups` (was + `/api/v1/sudoai/2d-mockups`) and the PSD template resource calls + `/api/v1/psd-mockups` (was `/api/v1/mockups`). The API keeps serving the + earlier paths, so an SDK pinned below 0.11.0 keeps working. +- **Job kinds on the family path.** An async submission made through + `/api/v1/photo-mockups` comes back with `kind` `photo_mockup_create` + (create) or `photo_mockup_render` (render), where 0.10.0 and earlier saw + `2d_create` / `2d_render`. That is the `kind` on the `JobAccepted` returned by + `photo_mockups.create(..., is_async=True)` and + `photo_mockups.render(..., is_async=True)`, and on the `Job` returned by + `jobs.get` / `jobs.wait` for those jobs. Code that compares `kind` against the + literal `"2d_create"` or `"2d_render"` must compare against the pair: + `PHOTO_MOCKUP_CREATE_KINDS` and `PHOTO_MOCKUP_RENDER_KINDS` in + `sudomock.models` hold both spellings, `jobs.list(kind=...)` selects both + with either spelling, and `wait_for_2d_mockup` keeps accepting either. +- The `.url` shortcut of `PhotoMockupRender` raises + `ValueError("Photo mockup render contains no print files")` when there is + nothing to point at; the message used to carry the product's earlier name. +- Docstrings and the README name the product "photo mockup" throughout. + +### Deprecated +- `client.ai` and `client.mockups`. Both keep working and return the very same + resource objects as `client.photo_mockups` / `client.psd_mockups`; each + access emits a `DeprecationWarning` naming the replacement. + ## [0.10.0] - 2026-09-18 ### Added @@ -253,7 +292,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.10.0...HEAD +[Unreleased]: https://github.com/sudomock/sudomock-python/compare/v0.11.0...HEAD +[0.11.0]: https://github.com/sudomock/sudomock-python/compare/v0.10.0...v0.11.0 [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 diff --git a/README.md b/README.md index 1d88faf..60230e7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Official Python client for the [SudoMock](https://sudomock.com) Mockup Generator API. -Generate photorealistic product mockups from PSD templates or 2D mockups -- all from your Python code. +Generate photorealistic product mockups from PSD templates or photo mockups -- all from your Python code. [![PyPI](https://img.shields.io/pypi/v/sudomock)](https://pypi.org/project/sudomock/) [![Python](https://img.shields.io/pypi/pyversions/sudomock)](https://pypi.org/project/sudomock/) @@ -24,7 +24,7 @@ from sudomock import SudoMock client = SudoMock(api_key="sm_your_api_key") # 2. List your mockup templates -mockups = client.mockups.list(limit=10) +mockups = client.psd_mockups.list(limit=10) for m in mockups.mockups: print(f"{m.name} ({m.uuid})") @@ -46,7 +46,7 @@ outputs. `smart_objects` is optional for text-only renders, and `fit` defaults to `"overflow"`. ```python -mockup = client.mockups.get("mockup-uuid") +mockup = client.psd_mockups.get("mockup-uuid") name_layer = next(layer for layer in mockup.text_layers if layer.name == "Customer Name") if not name_layer.is_editable: raise ValueError("Customer Name is not editable") @@ -79,7 +79,7 @@ from sudomock import AsyncSudoMock async def main(): async with AsyncSudoMock(api_key="sm_your_api_key") as client: - mockups = await client.mockups.list() + mockups = await client.psd_mockups.list() render = await client.renders.create( mockup_uuid=mockups.mockups[0].uuid, smart_objects=[{ @@ -92,9 +92,9 @@ async def main(): asyncio.run(main()) ``` -## 2D mockups via API +## Photo mockups via API -Create a 2D mockup from a product image, wait until its print areas are ready, +Create a photo mockup from a product image, wait until its print areas are ready, then render your artwork. Creation costs 25 credits and rendering costs 5 credits. Unsuccessful creations are refunded automatically. @@ -119,14 +119,14 @@ from sudomock import SudoMock client = SudoMock(api_key="sm_your_api_key") -# Create the 2D mockup (synchronous by default -- returns the finished mockup) -mockup = client.ai.create( +# Create the photo mockup (synchronous by default -- returns the finished mockup) +mockup = client.photo_mockups.create( source_url="https://example.com/product.jpg", name="Product Front", idempotency_key="product-front-001", ) -render = client.ai.render( +render = client.photo_mockups.render( mockup_uuid=mockup.mockup_id, print_areas=[{ "uuid": mockup.quads[0].print_area_id, @@ -137,7 +137,7 @@ print(render.url) # A product surface is rendered directly with its surface UUID. if mockup.surfaces: - render = client.ai.render( + render = client.photo_mockups.render( mockup_uuid=mockup.mockup_id, print_areas=[{ "surface_uuid": mockup.surfaces[0].surface_uuid, @@ -146,7 +146,7 @@ if mockup.surfaces: ) # Async variant: submit to the server queue and poll (returns a JobAccepted) -job = client.ai.render( +job = client.photo_mockups.render( mockup_uuid=mockup.mockup_id, print_areas=[{ "uuid": mockup.quads[0].print_area_id, @@ -234,7 +234,7 @@ render = client.renders.create( ``` To clean artwork inline during a render instead, set `remove_background` on the -render asset or 2D print area. It adds **25 credits per unique artwork** to the +render asset or photo mockup print area. It adds **25 credits per unique artwork** to the render (the same artwork reused across several smart objects or print areas is charged once). @@ -248,8 +248,8 @@ client.renders.create( }], ) -# 2D render -client.ai.render( +# Photo mockup render +client.photo_mockups.render( mockup_uuid="mockup-uuid", print_areas=[{ "uuid": "print-area-uuid", @@ -383,8 +383,8 @@ session = client.studio.create_session( # Keep session.bootstrap_secret on the trusted parent page for the required handshake. ``` -`allowed_origin`, `product_id`, and `variant_id` work for both PSD and 2D -sessions. PSD supports `customize`; 2D supports `setup` and `customize`. +`allowed_origin`, `product_id`, and `variant_id` work for both PSD and photo mockup +sessions. PSD supports `customize`; photo mockup supports `setup` and `customize`. Every response includes `session`, `expires_in`, `message_session_id`, and `bootstrap_secret`. Never put the bootstrap secret in the iframe URL or logs. @@ -449,7 +449,7 @@ fraction, so it has no denominator to be a percentage of. ### Pricing in one paragraph Pay as you go is the entry tier and needs no subscription: one PSD render costs -$0.10, so $1 covers 10, and the minimum first payment is $5. 2D Mockups and video +$0.10, so $1 covers 10, and the minimum first payment is $5. Photo mockups and video are priced by what they cost to produce, not at the flat render rate. Volume plans start at $25/month for 5,000 renders. A new account gets 500 credits once, with no card required to spend them, but until a card is verified its renders are @@ -479,10 +479,10 @@ client = SudoMock( | Method | Description | |--------|-------------| -| `client.mockups.list(limit=, offset=, name=, created_after=, created_before=, sort=, order=)` | List mockup templates (filter by `name`) | -| `client.mockups.get(uuid)` | Get mockup details | -| `client.mockups.update(uuid, name=)` | Rename a mockup | -| `client.mockups.delete(uuid)` | Delete a mockup | +| `client.psd_mockups.list(limit=, offset=, name=, created_after=, created_before=, sort=, order=)` | List mockup templates (filter by `name`) | +| `client.psd_mockups.get(uuid)` | Get mockup details | +| `client.psd_mockups.update(uuid, name=)` | Rename a mockup | +| `client.psd_mockups.delete(uuid)` | Delete a mockup | > Bulk delete (`DELETE /mockups/all`) is dashboard-only (Bearer/JWT auth) and is intentionally not exposed in this api-key SDK. @@ -507,17 +507,17 @@ client = SudoMock( |--------|-------------| | `client.psd.upload(url=, name=None, is_async=False)` | Upload a PSD by URL (free; sync `Mockup` or `JobAccepted`) | -### 2D Mockups +### Photo Mockups | 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 (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 | -| `client.ai.get(mockup_id)` | Get a 2D mockup | -| `client.ai.delete(mockup_id)` | Delete a 2D mockup | +| `client.photo_mockups.create(source_url=, source_base64=, name=, print_areas=, is_async=False, idempotency_key=)` | Create a photo mockup (25 credits; sync `PhotoMockup` by default, or `JobAccepted` when `is_async=True`) | +| `client.photo_mockups.wait_for_2d_mockup(job_id, poll_interval=2.0, timeout=180.0)` | Wait for an `is_async=True` creation and return the full photo mockup (accepts a job of kind `2d_create` or `photo_mockup_create`) | +| `client.photo_mockups.update_2d_print_areas(mockup_id, print_areas)` | Replace a photo mockup's print areas (free) | +| `client.photo_mockups.render(mockup_uuid=, print_areas=, export_options=, is_async=False)` | Render artwork onto a photo mockup (5 credits; sync `PhotoMockupRender` with `render_uuid` by default, or `JobAccepted` when `is_async=True`) | +| `client.photo_mockups.list(limit=, offset=, customizable_only=)` | List your photo mockups; set `customizable_only=True` for shopper-ready items | +| `client.photo_mockups.get(mockup_id)` | Get a photo mockup | +| `client.photo_mockups.delete(mockup_id)` | Delete a photo mockup | ### Images diff --git a/pyproject.toml b/pyproject.toml index 5dde2d4..57f2b94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sudomock" -version = "0.10.0" +version = "0.11.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 c9987c9..12223d9 100644 --- a/src/sudomock/__init__.py +++ b/src/sudomock/__init__.py @@ -5,7 +5,7 @@ from sudomock import SudoMock client = SudoMock(api_key="sm_xxx") - mockups = client.mockups.list() + mockups = client.psd_mockups.list() render = client.renders.create( mockup_uuid=mockups.mockups[0].uuid, smart_objects=[{ @@ -20,7 +20,7 @@ from sudomock import AsyncSudoMock async with AsyncSudoMock(api_key="sm_xxx") as client: - mockups = await client.mockups.list() + mockups = await client.psd_mockups.list() """ from __future__ import annotations @@ -54,6 +54,10 @@ JobList, Mockup, MockupList, + PhotoMockup, + PhotoMockupList, + PhotoMockupPrintAreasUpdate, + PhotoMockupRender, Plan, PlanList, PrintFile, @@ -104,6 +108,10 @@ "JobList", "Mockup", "MockupList", + "PhotoMockup", + "PhotoMockupList", + "PhotoMockupPrintAreasUpdate", + "PhotoMockupRender", "Plan", "PlanList", "PrintFile", diff --git a/src/sudomock/async_client.py b/src/sudomock/async_client.py index 090d9fd..916603b 100644 --- a/src/sudomock/async_client.py +++ b/src/sudomock/async_client.py @@ -14,7 +14,7 @@ from sudomock import AsyncSudoMock async with AsyncSudoMock(api_key="sm_xxx") as client: - mockups = await client.mockups.list(limit=20) + mockups = await client.psd_mockups.list(limit=20) render = await client.renders.create( mockup_uuid="...", smart_objects=[{"uuid": "...", "asset": {"url": "https://..."}}], @@ -34,6 +34,7 @@ import asyncio import os import time +import warnings from typing import Any, Literal, Optional, Union from uuid import uuid4 @@ -49,7 +50,6 @@ from .models import ( PHOTO_MOCKUP_CREATE_KINDS, AccountInfo, - AIRender, BackgroundRemoval, Job, JobAccepted, @@ -57,6 +57,10 @@ JobList, Mockup, MockupList, + PhotoMockup, + PhotoMockupList, + PhotoMockupPrintAreasUpdate, + PhotoMockupRender, PlanList, Render, StudioActionContext, @@ -64,9 +68,6 @@ StudioResultEvent, StudioSession, StudioSessionUi, - TwoDMockup, - TwoDMockupList, - TwoDPrintAreasUpdate, VideoOptions, WebhookDeliveryList, WebhookEndpoint, @@ -81,8 +82,8 @@ _StrList = list[str] -class _AsyncMockupsResource: - """Async mockup template operations.""" +class _AsyncPsdMockupsResource: + """Async PSD mockup template operations (list, get, update, delete).""" def __init__(self, transport: AsyncTransport) -> None: self._transport = transport @@ -115,7 +116,7 @@ async def list( """ resp = await self._transport.request( "GET", - "/api/v1/mockups", + "/api/v1/psd-mockups", params={ "limit": limit, "offset": offset, @@ -141,7 +142,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/mockups/{uuid}") + resp = await self._transport.request("GET", f"/api/v1/psd-mockups/{uuid}") payload = resp.json() return Mockup.model_validate({**payload["data"], "warnings": payload.get("warnings") or []}) @@ -159,7 +160,7 @@ async def update(self, uuid: str, *, name: str) -> Mockup: NotFoundError: If the mockup does not exist. """ resp = await self._transport.request( - "PATCH", f"/api/v1/mockups/{uuid}", json={"name": name} + "PATCH", f"/api/v1/psd-mockups/{uuid}", json={"name": name} ) payload = resp.json() return Mockup.model_validate({**payload["data"], "warnings": payload.get("warnings") or []}) @@ -173,7 +174,7 @@ async def delete(self, uuid: str) -> None: Raises: NotFoundError: If the mockup does not exist. """ - await self._transport.request("DELETE", f"/api/v1/mockups/{uuid}") + await self._transport.request("DELETE", f"/api/v1/psd-mockups/{uuid}") class _AsyncRendersResource: @@ -597,8 +598,8 @@ async def replay_failed(self, uuid: str) -> None: ) -class _AsyncAIResource: - """Async SudoAI 2D-mockup operations.""" +class _AsyncPhotoMockupsResource: + """Async photo mockup operations (create, render, list, get, delete, print areas).""" def __init__(self, transport: AsyncTransport) -> None: self._transport = transport @@ -612,8 +613,8 @@ async def create( print_areas: Optional[list[dict[str, Any]]] = None, is_async: bool = False, idempotency_key: Optional[str] = None, - ) -> Union[TwoDMockup, JobAccepted]: - """Create a 2D mockup from a source image (costs 25 credits). + ) -> Union[PhotoMockup, JobAccepted]: + """Create a photo mockup from a source image (costs 25 credits). Exactly one source must be supplied. By default the mockup is created synchronously and returned in full (HTTP 201). Pass ``is_async=True`` to @@ -633,7 +634,7 @@ async def create( UUID is generated when omitted. Returns: - :class:`TwoDMockup` (synchronous default), or :class:`JobAccepted` + :class:`PhotoMockup` (synchronous default), or :class:`JobAccepted` with ``job_id`` and ``status_url`` when ``is_async=True``. Raises: @@ -657,7 +658,7 @@ async def create( resp = await self._transport.request( "POST", - "/api/v1/sudoai/2d-mockups", + "/api/v1/photo-mockups", json=body, headers={"Idempotency-Key": idempotency_key}, ) @@ -666,7 +667,7 @@ async def create( # status_url} — no {success, data} envelope. return JobAccepted.model_validate(resp.json()) # Sync default (201) returns the full mockup in a {success, data} envelope. - return TwoDMockup.model_validate(resp.json()["data"]) + return PhotoMockup.model_validate(resp.json()["data"]) async def wait_for_2d_mockup( self, @@ -674,8 +675,8 @@ async def wait_for_2d_mockup( *, poll_interval: float = 2.0, timeout: float = 180.0, - ) -> TwoDMockup: - """Wait for a 2D-mockup creation job and return its full details. + ) -> PhotoMockup: + """Wait for a photo mockup creation job and return its full details. Raises: JobFailedError: If creation fails. The exception exposes the @@ -712,7 +713,7 @@ async def update_2d_print_areas( self, mockup_id: str, print_areas: list[dict[str, Any]], - ) -> TwoDPrintAreasUpdate: + ) -> PhotoMockupPrintAreasUpdate: """Replace up to eight print areas. An empty list is accepted only when the API has verified every product @@ -721,10 +722,10 @@ async def update_2d_print_areas( """ resp = await self._transport.request( "PUT", - f"/api/v1/sudoai/2d-mockups/{mockup_id}/print-areas", + f"/api/v1/photo-mockups/{mockup_id}/print-areas", json={"print_areas": print_areas}, ) - return TwoDPrintAreasUpdate.model_validate(resp.json()["data"]) + return PhotoMockupPrintAreasUpdate.model_validate(resp.json()["data"]) async def render( self, @@ -733,17 +734,17 @@ async def render( print_areas: list[dict[str, Any]], export_options: Optional[dict[str, Any]] = None, is_async: bool = False, - ) -> Union[AIRender, JobAccepted]: - """Render artwork onto an existing 2D mockup (costs 5 credits). + ) -> Union[PhotoMockupRender, JobAccepted]: + """Render artwork onto an existing photo mockup (costs 5 credits). By default the render runs synchronously and the finished - :class:`AIRender` is returned from the ``200`` response. Pass + :class:`PhotoMockupRender` is returned from the ``200`` response. Pass ``is_async=True`` to submit to the server-side queue and get a :class:`JobAccepted` (HTTP 202) to poll with :meth:`jobs.get` or :meth:`jobs.wait`; the terminal job carries ``result_url``. Args: - mockup_uuid: UUID of a previously-created 2D mockup (see + mockup_uuid: UUID of a previously-created photo mockup (see :meth:`list` / :meth:`get`). print_areas: One or more target configs. Each is a dict with exactly one identifier: ``uuid`` for a saved print area or @@ -772,7 +773,7 @@ async def render( ``False`` (synchronous 200 + full render). Returns: - :class:`AIRender` (synchronous default) with ``print_files``, + :class:`PhotoMockupRender` (synchronous default) with ``print_files``, ``render_uuid`` and a convenience ``.url`` property, or :class:`JobAccepted` with ``job_id`` and ``status_url`` when ``is_async=True``. @@ -792,16 +793,16 @@ async def render( resp = await self._transport.request( "POST", - f"/api/v1/sudoai/2d-mockups/{mockup_uuid}/render", + f"/api/v1/photo-mockups/{mockup_uuid}/render", json=body, timeout=self._transport._render_timeout, ) if is_async or resp.status_code == 202: - # Async submit (202) returns a BARE body {job_id, kind:"2d_render", + # Async submit (202) returns a BARE body {job_id, kind:"photo_mockup_render", # status, status_url} — no {success, data} envelope. The sync path # still wraps in {success, data}. return JobAccepted.model_validate(resp.json()) - return AIRender.model_validate(resp.json()["data"]) + return PhotoMockupRender.model_validate(resp.json()["data"]) async def list( self, @@ -809,11 +810,11 @@ async def list( limit: Optional[int] = None, offset: Optional[int] = None, customizable_only: Optional[bool] = None, - ) -> TwoDMockupList: - """List your SudoAI 2D mockups (free; zero credits).""" + ) -> PhotoMockupList: + """List your photo mockups (free; zero credits).""" resp = await self._transport.request( "GET", - "/api/v1/sudoai/2d-mockups", + "/api/v1/photo-mockups", params={ "limit": limit, "offset": offset, @@ -821,29 +822,29 @@ async def list( }, ) payload = resp.json() - return TwoDMockupList( + return PhotoMockupList( mockups=payload["data"], total=payload["total"], limit=payload["limit"], offset=payload["offset"], ) - async def get(self, mockup_id: str) -> TwoDMockup: - """Get a single 2D mockup by id (free; zero credits). + async def get(self, mockup_id: str) -> PhotoMockup: + """Get a single photo mockup by id (free; zero credits). Raises: - NotFoundError: If the 2D mockup does not exist. + NotFoundError: If the photo mockup does not exist. """ - resp = await self._transport.request("GET", f"/api/v1/sudoai/2d-mockups/{mockup_id}") - return TwoDMockup.model_validate(resp.json()["data"]) + resp = await self._transport.request("GET", f"/api/v1/photo-mockups/{mockup_id}") + return PhotoMockup.model_validate(resp.json()["data"]) async def delete(self, mockup_id: str) -> None: - """Delete a 2D mockup and all of its associated data (free; zero credits). + """Delete a photo mockup and all of its associated data (free; zero credits). Raises: - NotFoundError: If the 2D mockup does not exist. + NotFoundError: If the photo mockup does not exist. """ - await self._transport.request("DELETE", f"/api/v1/sudoai/2d-mockups/{mockup_id}") + await self._transport.request("DELETE", f"/api/v1/photo-mockups/{mockup_id}") class _AsyncImagesResource: @@ -1031,7 +1032,7 @@ class AsyncSudoMock: Usage:: async with AsyncSudoMock(api_key="sm_xxx") as client: - mockups = await client.mockups.list() + mockups = await client.psd_mockups.list() """ def __init__( @@ -1065,17 +1066,47 @@ def __init__( ) # Resource namespaces - self.mockups = _AsyncMockupsResource(self._transport) + self.psd_mockups = _AsyncPsdMockupsResource(self._transport) self.renders = _AsyncRendersResource(self._transport) self.jobs = _AsyncJobsResource(self._transport) self.psd = _AsyncPsdResource(self._transport) - self.ai = _AsyncAIResource(self._transport) + self.photo_mockups = _AsyncPhotoMockupsResource(self._transport) self.images = _AsyncImagesResource(self._transport) self.account = _AsyncAccountResource(self._transport) self.studio = _AsyncStudioResource(self._transport) self.packages = _AsyncPackagesResource(self._transport) self.webhook_endpoints = _AsyncWebhookEndpointsResource(self._transport) + @property + def ai(self) -> _AsyncPhotoMockupsResource: + """Earlier name of :attr:`photo_mockups`; emits a ``DeprecationWarning``.""" + warnings.warn( + "AsyncSudoMock.ai is deprecated; use AsyncSudoMock.photo_mockups instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.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] + + @property + def mockups(self) -> _AsyncPsdMockupsResource: + """Earlier name of :attr:`psd_mockups`; emits a ``DeprecationWarning``.""" + warnings.warn( + "AsyncSudoMock.mockups is deprecated; use AsyncSudoMock.psd_mockups instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.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] + async def close(self) -> None: """Close the underlying HTTP connection pool.""" await self._transport.close() diff --git a/src/sudomock/client.py b/src/sudomock/client.py index 9077488..23f7c39 100644 --- a/src/sudomock/client.py +++ b/src/sudomock/client.py @@ -5,7 +5,7 @@ from sudomock import SudoMock client = SudoMock(api_key="sm_xxx") # or set SUDOMOCK_API_KEY env var - mockups = client.mockups.list(limit=20) + mockups = client.psd_mockups.list(limit=20) render = client.renders.create( mockup_uuid="...", smart_objects=[{"uuid": "...", "asset": {"url": "https://..."}}], @@ -18,6 +18,7 @@ import os import time +import warnings from typing import Any, Literal, Optional, Union from uuid import uuid4 @@ -33,7 +34,6 @@ from .models import ( PHOTO_MOCKUP_CREATE_KINDS, AccountInfo, - AIRender, BackgroundRemoval, Job, JobAccepted, @@ -41,6 +41,10 @@ JobList, Mockup, MockupList, + PhotoMockup, + PhotoMockupList, + PhotoMockupPrintAreasUpdate, + PhotoMockupRender, PlanList, Render, StudioActionContext, @@ -48,9 +52,6 @@ StudioResultEvent, StudioSession, StudioSessionUi, - TwoDMockup, - TwoDMockupList, - TwoDPrintAreasUpdate, VideoOptions, WebhookDeliveryList, WebhookEndpoint, @@ -65,8 +66,8 @@ _StrList = list[str] -class _MockupsResource: - """Mockup template operations (list, get, delete).""" +class _PsdMockupsResource: + """PSD mockup template operations (list, get, update, delete).""" def __init__(self, transport: SyncTransport) -> None: self._transport = transport @@ -99,7 +100,7 @@ def list( """ resp = self._transport.request( "GET", - "/api/v1/mockups", + "/api/v1/psd-mockups", params={ "limit": limit, "offset": offset, @@ -125,7 +126,7 @@ def get(self, uuid: str) -> Mockup: Raises: NotFoundError: If the mockup does not exist. """ - resp = self._transport.request("GET", f"/api/v1/mockups/{uuid}") + resp = self._transport.request("GET", f"/api/v1/psd-mockups/{uuid}") payload = resp.json() return Mockup.model_validate({**payload["data"], "warnings": payload.get("warnings") or []}) @@ -142,7 +143,7 @@ def update(self, uuid: str, *, name: str) -> Mockup: Raises: NotFoundError: If the mockup does not exist. """ - resp = self._transport.request("PATCH", f"/api/v1/mockups/{uuid}", json={"name": name}) + resp = self._transport.request("PATCH", f"/api/v1/psd-mockups/{uuid}", json={"name": name}) payload = resp.json() return Mockup.model_validate({**payload["data"], "warnings": payload.get("warnings") or []}) @@ -155,7 +156,7 @@ def delete(self, uuid: str) -> None: Raises: NotFoundError: If the mockup does not exist. """ - self._transport.request("DELETE", f"/api/v1/mockups/{uuid}") + self._transport.request("DELETE", f"/api/v1/psd-mockups/{uuid}") class _RendersResource: @@ -636,8 +637,8 @@ def replay_failed(self, uuid: str) -> None: ) -class _AIResource: - """SudoAI 2D-mockup operations.""" +class _PhotoMockupsResource: + """Photo mockup operations (create, render, list, get, delete, print areas).""" def __init__(self, transport: SyncTransport) -> None: self._transport = transport @@ -651,8 +652,8 @@ def create( print_areas: Optional[list[dict[str, Any]]] = None, is_async: bool = False, idempotency_key: Optional[str] = None, - ) -> Union[TwoDMockup, JobAccepted]: - """Create a 2D mockup from a source image (costs 25 credits). + ) -> Union[PhotoMockup, JobAccepted]: + """Create a photo mockup from a source image (costs 25 credits). Exactly one source must be supplied. By default the mockup is created synchronously and returned in full (HTTP 201). Pass ``is_async=True`` to @@ -672,7 +673,7 @@ def create( UUID is generated when omitted. Returns: - :class:`TwoDMockup` (synchronous default), or :class:`JobAccepted` + :class:`PhotoMockup` (synchronous default), or :class:`JobAccepted` with ``job_id`` and ``status_url`` when ``is_async=True``. Raises: @@ -696,7 +697,7 @@ def create( resp = self._transport.request( "POST", - "/api/v1/sudoai/2d-mockups", + "/api/v1/photo-mockups", json=body, headers={"Idempotency-Key": idempotency_key}, ) @@ -705,7 +706,7 @@ def create( # status_url} — no {success, data} envelope. return JobAccepted.model_validate(resp.json()) # Sync default (201) returns the full mockup in a {success, data} envelope. - return TwoDMockup.model_validate(resp.json()["data"]) + return PhotoMockup.model_validate(resp.json()["data"]) def wait_for_2d_mockup( self, @@ -713,8 +714,8 @@ def wait_for_2d_mockup( *, poll_interval: float = 2.0, timeout: float = 180.0, - ) -> TwoDMockup: - """Wait for a 2D-mockup creation job and return its full details. + ) -> PhotoMockup: + """Wait for a photo mockup creation job and return its full details. Raises: JobFailedError: If creation fails. The exception exposes the @@ -751,11 +752,11 @@ def update_2d_print_areas( self, mockup_id: str, print_areas: list[dict[str, Any]], - ) -> TwoDPrintAreasUpdate: - """Replace a 2D mockup's print areas (free; zero credits). + ) -> PhotoMockupPrintAreasUpdate: + """Replace a photo mockup's print areas (free; zero credits). Args: - mockup_id: 2D mockup identifier. + mockup_id: photo mockup identifier. print_areas: Up to eight print areas, each with four ``[x, y]`` points and an optional ``name``. An empty list is accepted only when the API has verified every product surface as printable; @@ -766,10 +767,10 @@ def update_2d_print_areas( """ resp = self._transport.request( "PUT", - f"/api/v1/sudoai/2d-mockups/{mockup_id}/print-areas", + f"/api/v1/photo-mockups/{mockup_id}/print-areas", json={"print_areas": print_areas}, ) - return TwoDPrintAreasUpdate.model_validate(resp.json()["data"]) + return PhotoMockupPrintAreasUpdate.model_validate(resp.json()["data"]) def render( self, @@ -778,17 +779,17 @@ def render( print_areas: list[dict[str, Any]], export_options: Optional[dict[str, Any]] = None, is_async: bool = False, - ) -> Union[AIRender, JobAccepted]: - """Render artwork onto an existing 2D mockup (costs 5 credits). + ) -> Union[PhotoMockupRender, JobAccepted]: + """Render artwork onto an existing photo mockup (costs 5 credits). By default the render runs synchronously and the finished - :class:`AIRender` is returned from the ``200`` response. Pass + :class:`PhotoMockupRender` is returned from the ``200`` response. Pass ``is_async=True`` to submit to the server-side queue and get a :class:`JobAccepted` (HTTP 202) to poll with :meth:`SudoMock.jobs.get` or :meth:`SudoMock.jobs.wait`; the terminal job carries ``result_url``. Args: - mockup_uuid: UUID of a previously-created 2D mockup (see + mockup_uuid: UUID of a previously-created photo mockup (see :meth:`list` / :meth:`get`). print_areas: One or more target configs. Each is a dict with exactly one identifier: ``uuid`` for a saved print area or @@ -817,7 +818,7 @@ def render( ``False`` (synchronous 200 + full render). Returns: - :class:`AIRender` (synchronous default) with ``print_files``, + :class:`PhotoMockupRender` (synchronous default) with ``print_files``, ``render_uuid`` and a convenience ``.url`` property, or :class:`JobAccepted` with ``job_id`` and ``status_url`` when ``is_async=True``. @@ -837,16 +838,16 @@ def render( resp = self._transport.request( "POST", - f"/api/v1/sudoai/2d-mockups/{mockup_uuid}/render", + f"/api/v1/photo-mockups/{mockup_uuid}/render", json=body, timeout=self._transport._render_timeout, ) if is_async or resp.status_code == 202: - # Async submit (202) returns a BARE body {job_id, kind:"2d_render", + # Async submit (202) returns a BARE body {job_id, kind:"photo_mockup_render", # status, status_url} — no {success, data} envelope. The sync path # still wraps in {success, data}. return JobAccepted.model_validate(resp.json()) - return AIRender.model_validate(resp.json()["data"]) + return PhotoMockupRender.model_validate(resp.json()["data"]) def list( self, @@ -854,8 +855,8 @@ def list( limit: Optional[int] = None, offset: Optional[int] = None, customizable_only: Optional[bool] = None, - ) -> TwoDMockupList: - """List your SudoAI 2D mockups (free; zero credits). + ) -> PhotoMockupList: + """List your photo mockups (free; zero credits). Args: limit: Page size, 1..100 (default server-side: 20). @@ -863,12 +864,12 @@ def list( customizable_only: Return only mockups ready for shopper customization. Returns: - :class:`TwoDMockupList` with ``mockups``, ``total``, ``limit``, + :class:`PhotoMockupList` with ``mockups``, ``total``, ``limit``, ``offset``. """ resp = self._transport.request( "GET", - "/api/v1/sudoai/2d-mockups", + "/api/v1/photo-mockups", params={ "limit": limit, "offset": offset, @@ -876,29 +877,29 @@ def list( }, ) payload = resp.json() - return TwoDMockupList( + return PhotoMockupList( mockups=payload["data"], total=payload["total"], limit=payload["limit"], offset=payload["offset"], ) - def get(self, mockup_id: str) -> TwoDMockup: - """Get a single 2D mockup by id (free; zero credits). + def get(self, mockup_id: str) -> PhotoMockup: + """Get a single photo mockup by id (free; zero credits). Raises: - NotFoundError: If the 2D mockup does not exist. + NotFoundError: If the photo mockup does not exist. """ - resp = self._transport.request("GET", f"/api/v1/sudoai/2d-mockups/{mockup_id}") - return TwoDMockup.model_validate(resp.json()["data"]) + resp = self._transport.request("GET", f"/api/v1/photo-mockups/{mockup_id}") + return PhotoMockup.model_validate(resp.json()["data"]) def delete(self, mockup_id: str) -> None: - """Delete a 2D mockup and all of its associated data (free; zero credits). + """Delete a photo mockup and all of its associated data (free; zero credits). Raises: - NotFoundError: If the 2D mockup does not exist. + NotFoundError: If the photo mockup does not exist. """ - self._transport.request("DELETE", f"/api/v1/sudoai/2d-mockups/{mockup_id}") + self._transport.request("DELETE", f"/api/v1/photo-mockups/{mockup_id}") class _ImagesResource: @@ -921,7 +922,7 @@ def remove_background( render instead, set ``"remove_background": True`` on the render asset (:meth:`SudoMock.renders.create`) or print area - (:meth:`SudoMock.ai.render`). + (:meth:`SudoMock.photo_mockups.render`). Exactly one image source must be supplied. Credits are refunded automatically if processing fails. @@ -1087,12 +1088,12 @@ class SudoMock: Usage:: client = SudoMock(api_key="sm_xxx") - mockups = client.mockups.list() + mockups = client.psd_mockups.list() client.close() # Or as a context manager: with SudoMock(api_key="sm_xxx") as client: - mockups = client.mockups.list() + mockups = client.psd_mockups.list() """ def __init__( @@ -1126,17 +1127,47 @@ def __init__( ) # Resource namespaces - self.mockups = _MockupsResource(self._transport) + self.psd_mockups = _PsdMockupsResource(self._transport) self.renders = _RendersResource(self._transport) self.jobs = _JobsResource(self._transport) self.psd = _PsdResource(self._transport) - self.ai = _AIResource(self._transport) + self.photo_mockups = _PhotoMockupsResource(self._transport) self.images = _ImagesResource(self._transport) self.account = _AccountResource(self._transport) self.studio = _StudioResource(self._transport) self.packages = _PackagesResource(self._transport) self.webhook_endpoints = _WebhookEndpointsResource(self._transport) + @property + def ai(self) -> _PhotoMockupsResource: + """Earlier name of :attr:`photo_mockups`; emits a ``DeprecationWarning``.""" + warnings.warn( + "SudoMock.ai is deprecated; use SudoMock.photo_mockups instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.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] + + @property + def mockups(self) -> _PsdMockupsResource: + """Earlier name of :attr:`psd_mockups`; emits a ``DeprecationWarning``.""" + warnings.warn( + "SudoMock.mockups is deprecated; use SudoMock.psd_mockups instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.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] + def close(self) -> None: """Close the underlying HTTP connection pool.""" self._transport.close() diff --git a/src/sudomock/models.py b/src/sudomock/models.py index 2cdb94d..4ce4909 100644 --- a/src/sudomock/models.py +++ b/src/sudomock/models.py @@ -330,18 +330,18 @@ class PrintFile(_Outcome): """A single rendered output file. Returned by both the still-render path (``renders.create`` — - ``smart_object_uuid`` set, plus ``render_uuid``) and the SudoAI 2D-render - path (``ai.render`` — ``smart_object_uuid`` absent, ``duration_ms`` / + ``smart_object_uuid`` set, plus ``render_uuid``) and the photo mockup render + path (``photo_mockups.render`` — ``smart_object_uuid`` absent, ``duration_ms`` / ``export_format`` present instead). Fields that only appear on one path are optional so a single model covers both. """ export_path: str - # Present on still renders; the SudoAI 2D-render print_files omit it. + # Present on still renders; photo mockup render print_files omit it. smart_object_uuid: Optional[str] = None # Present on still renders (route-level field, not always in the envelope). render_uuid: Optional[str] = None - # Present on SudoAI 2D-render print_files. + # Present on photo mockup render print_files. duration_ms: Optional[int] = None export_format: Optional[str] = None @@ -367,14 +367,14 @@ def url(self) -> str: # --------------------------------------------------------------------------- -# SudoAI 2D render +# Photo mockup render # --------------------------------------------------------------------------- -class AIRender(_Outcome): - """Result of a SudoAI 2D-mockup render (``POST /sudoai/2d-mockups/{id}/render``). +class PhotoMockupRender(_Outcome): + """Result of a photo mockup render (``POST /photo-mockups/{id}/render``). - The 2D-render ``print_files`` carry ``export_path`` / ``duration_ms`` / + The ``print_files`` carry ``export_path`` / ``duration_ms`` / ``export_format`` (no ``smart_object_uuid``). The render transaction id is exposed as ``render_uuid`` (a sibling of ``print_files`` in the ``data`` envelope). @@ -387,12 +387,12 @@ class AIRender(_Outcome): def url(self) -> str: """Shortcut: URL of the first print file.""" if not self.print_files: - raise ValueError("AI render contains no print files") + raise ValueError("Photo mockup render contains no print files") return self.print_files[0].url class Quad(_Outcome): - """A printable four-point area on a 2D mockup.""" + """A printable four-point area on a photo mockup.""" print_area_id: str points: list[list[float]] @@ -414,15 +414,15 @@ class FullSurface(_Outcome): surface_uuid: str -class TwoDPrintAreasUpdate(_Outcome): - """Updated geometry returned after replacing 2D print areas.""" +class PhotoMockupPrintAreasUpdate(_Outcome): + """Updated geometry returned after replacing a photo mockup's print areas.""" mockup_id: str print_areas: list[Quad] = Field(default_factory=list) -class TwoDMockup(_Outcome): - """A SudoAI 2D mockup (``GET /sudoai/2d-mockups/{id}`` / list). +class PhotoMockup(_Outcome): + """A photo mockup (``GET /photo-mockups/{id}`` / list). The detail endpoint returns ``quads``; the list endpoint returns ``print_areas``. Both are accepted via ``extra='allow'``. @@ -444,19 +444,26 @@ class TwoDMockup(_Outcome): updated_at: Optional[datetime] = None -class TwoDMockupList(_Outcome): - """Paginated list of SudoAI 2D mockups. +class PhotoMockupList(_Outcome): + """Paginated list of photo mockups. The API returns ``{data: [...], total, limit, offset, success}``; the client lifts the array into ``mockups`` with the sibling pagination fields. """ - mockups: list[TwoDMockup] = Field(default_factory=list) + mockups: list[PhotoMockup] = Field(default_factory=list) total: int limit: int offset: int +# Earlier names of the photo mockup models: the same classes, kept importable. +AIRender = PhotoMockupRender +TwoDPrintAreasUpdate = PhotoMockupPrintAreasUpdate +TwoDMockup = PhotoMockup +TwoDMockupList = PhotoMockupList + + # --------------------------------------------------------------------------- # Background removal # --------------------------------------------------------------------------- @@ -504,7 +511,7 @@ class JobAccepted(_Outcome): """Acknowledgement returned by a ``202 Accepted`` async submission. Returned by ``renders.create(..., is_async=True)``, ``psd.upload(..., - is_async=True)``, ``renders.create_video(...)``, and ``ai.create(..., + is_async=True)``, ``renders.create_video(...)``, and ``photo_mockups.create(..., is_async=True)``. Poll for completion with :meth:`jobs.get` or :meth:`jobs.wait` using :attr:`job_id`. diff --git a/tests/conftest.py b/tests/conftest.py index e09e617..67e9fb4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -113,9 +113,9 @@ "warnings": [{"code": "TEXT_FIT_SHRUNK", "message": "Text was resized to fit."}], } -# SudoAI 2D render: print_files carry export_path / duration_ms / export_format +# Photo mockup render: print_files carry export_path / duration_ms / export_format # (NO smart_object_uuid), plus a sibling render_uuid. Matches -# POST /sudoai/2d-mockups/{id}/render. +# POST /photo-mockups/{id}/render. MOCK_AI_RENDER_RESPONSE = { "success": True, "data": { @@ -130,12 +130,12 @@ }, } -# SudoAI 2D render async submit (POST /sudoai/2d-mockups/{id}/render with -# is_async=true) returns a BARE 202 body {job_id, kind:"2d_render", status, +# Photo mockup render async submit (POST /photo-mockups/{id}/render with +# is_async=true) returns a BARE 202 body {job_id, kind:"photo_mockup_render", status, # status_url} — no {success, data} envelope. MOCK_AI_RENDER_JOB_ACCEPTED_RESPONSE = { "job_id": "2d-render-job-001", - "kind": "2d_render", + "kind": "photo_mockup_render", "status": "queued", "status_url": "/api/v1/jobs/2d-render-job-001", } @@ -151,7 +151,7 @@ }, } -# 2D mockup detail (GET /sudoai/2d-mockups/{id}) — has quads (with optional name). +# Photo mockup detail (GET /photo-mockups/{id}) — has quads (with optional name). MOCK_2D_MOCKUP = { "mockup_id": "2d-mockup-001", "name": "Flat Tee Front", @@ -170,11 +170,11 @@ MOCK_2D_MOCKUP_GET_RESPONSE = {"data": MOCK_2D_MOCKUP, "success": True} -# Sync create (POST /sudoai/2d-mockups, default is_async=false) returns 201 with +# Sync create (POST /photo-mockups, default is_async=false) returns 201 with # the full mockup in the {success, data} envelope — same shape as the detail GET. MOCK_2D_MOCKUP_CREATE_RESPONSE = MOCK_2D_MOCKUP_GET_RESPONSE -# 2D mockup list (GET /sudoai/2d-mockups) — data array + sibling pagination. +# Photo mockup list (GET /photo-mockups) — data array + sibling pagination. MOCK_2D_MOCKUP_LIST_RESPONSE = { "data": [ { @@ -210,29 +210,29 @@ MOCK_2D_MOCKUP_JOB_ACCEPTED_RESPONSE = { "job_id": "2d-create-job-001", - "kind": "2d_create", + "kind": "photo_mockup_create", "status": "queued", "status_url": "/api/v1/jobs/2d-create-job-001", } MOCK_2D_MOCKUP_JOB_QUEUED_RESPONSE = { "job_id": "2d-create-job-001", - "kind": "2d_create", + "kind": "photo_mockup_create", "status": "queued", } MOCK_2D_MOCKUP_JOB_SUCCEEDED_RESPONSE = { "job_id": "2d-create-job-001", - "kind": "2d_create", + "kind": "photo_mockup_create", "status": "succeeded", - "result_url": "/api/v1/sudoai/2d-mockups/2d-mockup-001", + "result_url": "/api/v1/photo-mockups/2d-mockup-001", "mockup_uuid": "2d-mockup-001", "error": None, } MOCK_2D_MOCKUP_JOB_FAILED_RESPONSE = { "job_id": "2d-create-job-001", - "kind": "2d_create", + "kind": "photo_mockup_create", "status": "failed", "result_url": None, "mockup_uuid": None, diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 496653c..6956808 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -100,22 +100,22 @@ async def test_async_context_manager(self) -> None: class TestAsyncMockupsList: async def test_list_mockups(self, mock_api: respx.MockRouter) -> None: - mock_api.get("/api/v1/mockups").mock( + mock_api.get("/api/v1/psd-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: - result = await client.mockups.list() + result = await client.psd_mockups.list() assert isinstance(result, MockupList) assert result.total == 1 assert result.mockups[0].uuid == MOCK_MOCKUP["uuid"] async def test_list_with_params(self, mock_api: respx.MockRouter) -> None: - route = mock_api.get("/api/v1/mockups").mock( + route = mock_api.get("/api/v1/psd-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(limit=10, offset=5) + await client.psd_mockups.list(limit=10, offset=5) request = route.calls.last.request assert request.url.params["limit"] == "10" @@ -125,11 +125,11 @@ async def test_list_with_params(self, mock_api: respx.MockRouter) -> None: class TestAsyncMockupsGet: async def test_get_mockup(self, mock_api: respx.MockRouter) -> None: uuid = MOCK_MOCKUP["uuid"] - mock_api.get(f"/api/v1/mockups/{uuid}").mock( + mock_api.get(f"/api/v1/psd-mockups/{uuid}").mock( return_value=httpx.Response(200, json=MOCK_MOCKUP_GET_RESPONSE) ) async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - result = await client.mockups.get(uuid) + result = await client.psd_mockups.get(uuid) assert isinstance(result, Mockup) assert result.name == "Black T-Shirt Front" @@ -139,9 +139,9 @@ async def test_get_mockup(self, mock_api: respx.MockRouter) -> None: class TestAsyncMockupsDelete: async def test_delete_mockup(self, mock_api: respx.MockRouter) -> None: uuid = "some-uuid" - mock_api.delete(f"/api/v1/mockups/{uuid}").mock(return_value=httpx.Response(204)) + mock_api.delete(f"/api/v1/psd-mockups/{uuid}").mock(return_value=httpx.Response(204)) async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - await client.mockups.delete(uuid) + await client.psd_mockups.delete(uuid) # --------------------------------------------------------------------------- @@ -228,17 +228,17 @@ async def test_insufficient_credits(self, mock_api: respx.MockRouter) -> None: # --------------------------------------------------------------------------- -# AI resource +# Photo mockups resource # --------------------------------------------------------------------------- class TestAsyncAI: async def test_ai_list_customizable_only(self, mock_api: respx.MockRouter) -> None: - route = mock_api.get("/api/v1/sudoai/2d-mockups").mock( + route = mock_api.get("/api/v1/photo-mockups").mock( return_value=httpx.Response(200, json=MOCK_2D_MOCKUP_LIST_RESPONSE) ) async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - listing = await client.ai.list( + listing = await client.photo_mockups.list( limit=20, customizable_only=True, ) @@ -250,11 +250,11 @@ async def test_ai_list_customizable_only(self, mock_api: respx.MockRouter) -> No assert route.calls.last.request.url.params["customizable_only"] == "true" async def test_ai_render(self, mock_api: respx.MockRouter) -> None: - route = mock_api.post("/api/v1/sudoai/2d-mockups/2d-mockup-001/render").mock( + route = mock_api.post("/api/v1/photo-mockups/2d-mockup-001/render").mock( return_value=httpx.Response(200, json=MOCK_AI_RENDER_RESPONSE) ) async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - result = await client.ai.render( + result = await client.photo_mockups.render( mockup_uuid="2d-mockup-001", print_areas=[{"uuid": "pa-1", "artwork_url": "https://x.com/d.png"}], ) @@ -269,11 +269,11 @@ async def test_ai_render(self, mock_api: respx.MockRouter) -> None: assert body["print_areas"][0]["uuid"] == "pa-1" async def test_ai_render_async(self, mock_api: respx.MockRouter) -> None: - route = mock_api.post("/api/v1/sudoai/2d-mockups/2d-mockup-001/render").mock( + route = mock_api.post("/api/v1/photo-mockups/2d-mockup-001/render").mock( return_value=httpx.Response(202, json=MOCK_AI_RENDER_JOB_ACCEPTED_RESPONSE) ) async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - result = await client.ai.render( + result = await client.photo_mockups.render( mockup_uuid="2d-mockup-001", print_areas=[{"uuid": "pa-1", "artwork_url": "https://x.com/d.png"}], is_async=True, @@ -282,7 +282,7 @@ async def test_ai_render_async(self, mock_api: respx.MockRouter) -> None: # Async submit: 202 returns a JobAccepted (job envelope), NOT an AIRender. assert isinstance(result, JobAccepted) assert result.job_id == "2d-render-job-001" - assert result.kind == "2d_render" + assert result.kind == "photo_mockup_render" assert result.status == "queued" assert result.status_url == "/api/v1/jobs/2d-render-job-001" @@ -294,11 +294,11 @@ async def test_ai_render_async(self, mock_api: respx.MockRouter) -> None: async def test_ai_render_product_surface_uses_surface_uuid( self, mock_api: respx.MockRouter ) -> None: - route = mock_api.post("/api/v1/sudoai/2d-mockups/2d-mockup-001/render").mock( + route = mock_api.post("/api/v1/photo-mockups/2d-mockup-001/render").mock( return_value=httpx.Response(200, json=MOCK_AI_RENDER_RESPONSE) ) async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - await client.ai.render( + await client.photo_mockups.render( mockup_uuid="2d-mockup-001", print_areas=[{"surface_uuid": "surface-1", "color": "#FF0000"}], ) @@ -307,11 +307,11 @@ async def test_ai_render_product_surface_uses_surface_uuid( assert surface == {"surface_uuid": "surface-1", "color": "#FF0000"} async def test_ai_create(self, mock_api: respx.MockRouter) -> None: - route = mock_api.post("/api/v1/sudoai/2d-mockups").mock( + route = mock_api.post("/api/v1/photo-mockups").mock( return_value=httpx.Response(201, 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.create( + result = await client.photo_mockups.create( source_url="https://example.com/product.jpg", name="Product Front", idempotency_key="create-2d-001", @@ -333,18 +333,18 @@ async def test_ai_create(self, mock_api: respx.MockRouter) -> None: assert route.calls.last.request.headers["x-api-key"] == TEST_API_KEY async def test_ai_create_async(self, mock_api: respx.MockRouter) -> None: - route = mock_api.post("/api/v1/sudoai/2d-mockups").mock( + route = mock_api.post("/api/v1/photo-mockups").mock( return_value=httpx.Response(202, json=MOCK_2D_MOCKUP_JOB_ACCEPTED_RESPONSE) ) async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - result = await client.ai.create( + result = await client.photo_mockups.create( source_url="https://example.com/product.jpg", is_async=True, ) assert isinstance(result, JobAccepted) assert result.job_id == "2d-create-job-001" - assert result.kind == "2d_create" + assert result.kind == "photo_mockup_create" assert result.status == "queued" assert result.status_url == "/api/v1/jobs/2d-create-job-001" assert json.loads(route.calls.last.request.content) == { @@ -355,7 +355,7 @@ async def test_ai_create_async(self, mock_api: respx.MockRouter) -> None: async def test_ai_create_base64_generates_stable_idempotency_key( self, mock_api: respx.MockRouter ) -> None: - route = mock_api.post("/api/v1/sudoai/2d-mockups") + route = mock_api.post("/api/v1/photo-mockups") route.side_effect = [ httpx.Response(500, json=ERROR_500), httpx.Response(202, json=MOCK_2D_MOCKUP_JOB_ACCEPTED_RESPONSE), @@ -366,7 +366,7 @@ async def test_ai_create_base64_generates_stable_idempotency_key( base_url=TEST_BASE_URL, max_retries=2, ) as client: - await client.ai.create(source_base64="aW1hZ2U=") + await client.photo_mockups.create(source_base64="aW1hZ2U=") assert json.loads(route.calls.last.request.content) == {"source_base64": "aW1hZ2U="} keys = [call.request.headers["idempotency-key"] for call in route.calls] @@ -375,24 +375,24 @@ async def test_ai_create_base64_generates_stable_idempotency_key( assert UUID(keys[0]).version == 4 async def test_ai_create_insufficient_credits(self, mock_api: respx.MockRouter) -> None: - route = mock_api.post("/api/v1/sudoai/2d-mockups").mock( + route = mock_api.post("/api/v1/photo-mockups").mock( return_value=httpx.Response(402, json=ERROR_402) ) async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: with pytest.raises(InsufficientCreditsError): - await client.ai.create(source_url="https://example.com/product.jpg") + await client.photo_mockups.create(source_url="https://example.com/product.jpg") assert len(route.calls) == 1 async def test_ai_create_requires_exactly_one_source(self, mock_api: respx.MockRouter) -> None: async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: with pytest.raises(ValueError, match="exactly one"): - await client.ai.create() + await client.photo_mockups.create() with pytest.raises(ValueError, match="exactly one"): - await client.ai.create(source_url="") + await client.photo_mockups.create(source_url="") with pytest.raises(ValueError, match="exactly one"): - await client.ai.create( + await client.photo_mockups.create( source_url="https://example.com/product.jpg", source_base64="aW1hZ2U=", ) @@ -405,12 +405,12 @@ async def test_ai_wait_for_2d_mockup_success(self, mock_api: respx.MockRouter) - httpx.Response(200, json=MOCK_2D_MOCKUP_JOB_QUEUED_RESPONSE), httpx.Response(200, json=MOCK_2D_MOCKUP_JOB_SUCCEEDED_RESPONSE), ] - detail_route = mock_api.get("/api/v1/sudoai/2d-mockups/2d-mockup-001").mock( + detail_route = mock_api.get("/api/v1/photo-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( + result = await client.photo_mockups.wait_for_2d_mockup( "2d-create-job-001", poll_interval=0.0, ) @@ -438,24 +438,26 @@ 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 a photo-mockup create job"): - await client.ai.wait_for_2d_mockup("video-job-001", poll_interval=0.0) + await client.photo_mockups.wait_for_2d_mockup("video-job-001", poll_interval=0.0) - async def test_ai_wait_for_2d_mockup_accepts_family_kind( + async def test_ai_wait_for_2d_mockup_accepts_earlier_kind( self, mock_api: respx.MockRouter ) -> None: - """A job stamped with the family kind is the same work as ``2d_create``.""" + """A job stamped with the earlier ``2d_create`` kind is the same work.""" 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"}, + json={**MOCK_2D_MOCKUP_JOB_SUCCEEDED_RESPONSE, "kind": "2d_create"}, ) ) - detail_route = mock_api.get("/api/v1/sudoai/2d-mockups/2d-mockup-001").mock( + detail_route = mock_api.get("/api/v1/photo-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) + result = await client.photo_mockups.wait_for_2d_mockup( + "2d-create-job-001", poll_interval=0.0 + ) assert isinstance(result, TwoDMockup) assert result.mockup_id == "2d-mockup-001" @@ -474,7 +476,9 @@ async def test_ai_wait_for_2d_mockup_missing_mockup_uuid( async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: with pytest.raises(SudoMockError, match="without a mockup UUID"): - await client.ai.wait_for_2d_mockup("2d-create-job-001", poll_interval=0.0) + await client.photo_mockups.wait_for_2d_mockup( + "2d-create-job-001", poll_interval=0.0 + ) async def test_ai_wait_for_2d_mockup_failure(self, mock_api: respx.MockRouter) -> None: mock_api.get("/api/v1/jobs/2d-create-job-001").mock( @@ -483,7 +487,9 @@ async def test_ai_wait_for_2d_mockup_failure(self, mock_api: respx.MockRouter) - async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: with pytest.raises(JobFailedError) as exc_info: - await client.ai.wait_for_2d_mockup("2d-create-job-001", poll_interval=0.0) + await client.photo_mockups.wait_for_2d_mockup( + "2d-create-job-001", poll_interval=0.0 + ) assert exc_info.value.job_id == "2d-create-job-001" assert exc_info.value.error_code == "NOT_MOCKUPABLE" @@ -496,7 +502,7 @@ async def test_ai_wait_for_2d_mockup_timeout(self, mock_api: respx.MockRouter) - async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: with pytest.raises(JobTimeoutError) as exc_info: - await client.ai.wait_for_2d_mockup( + await client.photo_mockups.wait_for_2d_mockup( "2d-create-job-001", poll_interval=0.0, timeout=0.0, @@ -505,13 +511,13 @@ async def test_ai_wait_for_2d_mockup_timeout(self, mock_api: respx.MockRouter) - assert exc_info.value.job_id == "2d-create-job-001" async def test_ai_update_2d_print_areas(self, mock_api: respx.MockRouter) -> None: - route = mock_api.put("/api/v1/sudoai/2d-mockups/2d-mockup-001/print-areas").mock( + route = mock_api.put("/api/v1/photo-mockups/2d-mockup-001/print-areas").mock( return_value=httpx.Response(200, json=MOCK_2D_PRINT_AREAS_UPDATE_RESPONSE) ) print_areas = [{"points": [[100, 100], [500, 100], [500, 500], [100, 500]]}] async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - result = await client.ai.update_2d_print_areas("2d-mockup-001", print_areas) + result = await client.photo_mockups.update_2d_print_areas("2d-mockup-001", print_areas) assert json.loads(route.calls.last.request.content) == {"print_areas": print_areas} assert isinstance(result, TwoDPrintAreasUpdate) @@ -522,7 +528,7 @@ async def test_ai_update_2d_print_areas(self, mock_api: respx.MockRouter) -> Non async def test_ai_update_2d_print_areas_forwards_empty_product_surface_state( self, mock_api: respx.MockRouter ) -> None: - route = mock_api.put("/api/v1/sudoai/2d-mockups/2d-mockup-001/print-areas").mock( + route = mock_api.put("/api/v1/photo-mockups/2d-mockup-001/print-areas").mock( return_value=httpx.Response( 200, json={ @@ -533,7 +539,7 @@ async def test_ai_update_2d_print_areas_forwards_empty_product_surface_state( ) async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - result = await client.ai.update_2d_print_areas("2d-mockup-001", []) + result = await client.photo_mockups.update_2d_print_areas("2d-mockup-001", []) assert json.loads(route.calls.last.request.content) == {"print_areas": []} assert result.print_areas == [] @@ -732,22 +738,22 @@ async def test_get_account(self, mock_api: respx.MockRouter) -> None: class TestAsyncErrorHandling: async def test_401_raises_auth_error(self, mock_api: respx.MockRouter) -> None: - mock_api.get("/api/v1/mockups").mock(return_value=httpx.Response(401, json=ERROR_401)) + mock_api.get("/api/v1/psd-mockups").mock(return_value=httpx.Response(401, json=ERROR_401)) async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: with pytest.raises(AuthenticationError): - await client.mockups.list() + await client.psd_mockups.list() async def test_429_raises_rate_limit_error(self, mock_api: respx.MockRouter) -> None: - mock_api.get("/api/v1/mockups").mock(return_value=httpx.Response(429, json=ERROR_429)) + mock_api.get("/api/v1/psd-mockups").mock(return_value=httpx.Response(429, json=ERROR_429)) async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: with pytest.raises(RateLimitError): - await client.mockups.list() + await client.psd_mockups.list() async def test_500_raises_server_error(self, mock_api: respx.MockRouter) -> None: - mock_api.get("/api/v1/mockups").mock(return_value=httpx.Response(500, json=ERROR_500)) + mock_api.get("/api/v1/psd-mockups").mock(return_value=httpx.Response(500, json=ERROR_500)) async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: with pytest.raises(ServerError): - await client.mockups.list() + await client.psd_mockups.list() # --------------------------------------------------------------------------- @@ -757,7 +763,7 @@ async def test_500_raises_server_error(self, mock_api: respx.MockRouter) -> None class TestAsyncRetry: async def test_retries_on_500(self, mock_api: respx.MockRouter) -> None: - route = mock_api.get("/api/v1/mockups") + route = mock_api.get("/api/v1/psd-mockups") route.side_effect = [ httpx.Response(500, json=ERROR_500), httpx.Response(200, json=MOCK_MOCKUP_LIST_RESPONSE), @@ -765,19 +771,19 @@ async def test_retries_on_500(self, mock_api: respx.MockRouter) -> None: async with AsyncSudoMock( api_key=TEST_API_KEY, base_url=TEST_BASE_URL, max_retries=2 ) as client: - result = await client.mockups.list() + result = await client.psd_mockups.list() assert result.total == 1 assert len(route.calls) == 2 async def test_no_retry_on_4xx(self, mock_api: respx.MockRouter) -> None: - route = mock_api.get("/api/v1/mockups") + route = mock_api.get("/api/v1/psd-mockups") route.mock(return_value=httpx.Response(401, json=ERROR_401)) async with AsyncSudoMock( api_key=TEST_API_KEY, base_url=TEST_BASE_URL, max_retries=3 ) as client: with pytest.raises(AuthenticationError): - await client.mockups.list() + await client.psd_mockups.list() assert len(route.calls) == 1 @@ -789,11 +795,11 @@ async def test_no_retry_on_4xx(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( + route = mock_api.get("/api/v1/psd-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() + await client.psd_mockups.list() headers = route.calls.last.request.headers expected = f"python-sdk/{sudomock.__version__}" diff --git a/tests/test_async_surface.py b/tests/test_async_surface.py index 853c5a5..f04ec53 100644 --- a/tests/test_async_surface.py +++ b/tests/test_async_surface.py @@ -304,11 +304,11 @@ def test_list_jobs(self, mock_api: respx.MockRouter) -> None: class TestMockupUpdate: def test_rename(self, mock_api: respx.MockRouter) -> None: uuid = MOCK_MOCKUP["uuid"] - route = mock_api.patch(f"/api/v1/mockups/{uuid}").mock( + route = mock_api.patch(f"/api/v1/psd-mockups/{uuid}").mock( return_value=httpx.Response(200, json=MOCK_MOCKUP_GET_RESPONSE) ) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - result = client.mockups.update(uuid, name="Renamed") + result = client.psd_mockups.update(uuid, name="Renamed") assert isinstance(result, Mockup) body = json.loads(route.calls.last.request.content) assert body == {"name": "Renamed"} diff --git a/tests/test_client.py b/tests/test_client.py index ffd13fe..4d3221b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -121,11 +121,11 @@ def test_context_manager(self) -> None: class TestMockupsList: def test_list_mockups(self, mock_api: respx.MockRouter) -> None: - mock_api.get("/api/v1/mockups").mock( + mock_api.get("/api/v1/psd-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: - result = client.mockups.list() + result = client.psd_mockups.list() assert isinstance(result, MockupList) assert result.total == 1 @@ -133,22 +133,22 @@ def test_list_mockups(self, mock_api: respx.MockRouter) -> None: assert result.mockups[0].uuid == MOCK_MOCKUP["uuid"] def test_list_with_params(self, mock_api: respx.MockRouter) -> None: - route = mock_api.get("/api/v1/mockups").mock( + route = mock_api.get("/api/v1/psd-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(limit=10, offset=5) + client.psd_mockups.list(limit=10, offset=5) request = route.calls.last.request assert request.url.params["limit"] == "10" assert request.url.params["offset"] == "5" def test_list_sends_api_key_header(self, mock_api: respx.MockRouter) -> None: - route = mock_api.get("/api/v1/mockups").mock( + route = mock_api.get("/api/v1/psd-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() + client.psd_mockups.list() request = route.calls.last.request assert request.headers["x-api-key"] == TEST_API_KEY @@ -157,11 +157,11 @@ def test_list_sends_api_key_header(self, mock_api: respx.MockRouter) -> None: class TestMockupsGet: def test_get_mockup(self, mock_api: respx.MockRouter) -> None: uuid = MOCK_MOCKUP["uuid"] - mock_api.get(f"/api/v1/mockups/{uuid}").mock( + mock_api.get(f"/api/v1/psd-mockups/{uuid}").mock( return_value=httpx.Response(200, json=MOCK_MOCKUP_GET_RESPONSE) ) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - result = client.mockups.get(uuid) + result = client.psd_mockups.get(uuid) assert isinstance(result, Mockup) assert result.uuid == uuid @@ -170,21 +170,21 @@ def test_get_mockup(self, mock_api: respx.MockRouter) -> None: assert result.text_layers[0].font_postscript_name == "Montserrat-Bold" def test_get_not_found(self, mock_api: respx.MockRouter) -> None: - mock_api.get("/api/v1/mockups/nonexistent").mock( + mock_api.get("/api/v1/psd-mockups/nonexistent").mock( return_value=httpx.Response(404, json=ERROR_404) ) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: with pytest.raises(NotFoundError): - client.mockups.get("nonexistent") + client.psd_mockups.get("nonexistent") class TestMockupsDelete: def test_delete_mockup(self, mock_api: respx.MockRouter) -> None: uuid = "some-uuid" - mock_api.delete(f"/api/v1/mockups/{uuid}").mock(return_value=httpx.Response(204)) + mock_api.delete(f"/api/v1/psd-mockups/{uuid}").mock(return_value=httpx.Response(204)) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: # Should not raise - client.mockups.delete(uuid) + client.psd_mockups.delete(uuid) # --------------------------------------------------------------------------- @@ -327,17 +327,17 @@ def test_insufficient_credits(self, mock_api: respx.MockRouter) -> None: # --------------------------------------------------------------------------- -# AI resource +# Photo mockups resource # --------------------------------------------------------------------------- class TestAI: def test_ai_render(self, mock_api: respx.MockRouter) -> None: - route = mock_api.post("/api/v1/sudoai/2d-mockups/2d-mockup-001/render").mock( + route = mock_api.post("/api/v1/photo-mockups/2d-mockup-001/render").mock( return_value=httpx.Response(200, json=MOCK_AI_RENDER_RESPONSE) ) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - result = client.ai.render( + result = client.photo_mockups.render( mockup_uuid="2d-mockup-001", print_areas=[ { @@ -350,7 +350,7 @@ def test_ai_render(self, mock_api: respx.MockRouter) -> None: assert isinstance(result, AIRender) assert "ai-renders" in result.url assert result.render_uuid == "render-2d-001" - # 2D-render print_files have no smart_object_uuid. + # Photo mockup render print_files have no smart_object_uuid. assert result.print_files[0].smart_object_uuid is None assert result.print_files[0].export_format == "webp" @@ -363,11 +363,11 @@ def test_ai_render(self, mock_api: respx.MockRouter) -> None: assert "product_type" not in body def test_ai_render_with_options(self, mock_api: respx.MockRouter) -> None: - route = mock_api.post("/api/v1/sudoai/2d-mockups/2d-mockup-001/render").mock( + route = mock_api.post("/api/v1/photo-mockups/2d-mockup-001/render").mock( return_value=httpx.Response(200, json=MOCK_AI_RENDER_RESPONSE) ) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - client.ai.render( + client.photo_mockups.render( mockup_uuid="2d-mockup-001", print_areas=[{"uuid": "pa-1", "color": "#FF0000"}], export_options={"image_format": "png"}, @@ -378,11 +378,11 @@ def test_ai_render_with_options(self, mock_api: respx.MockRouter) -> None: assert body["export_options"]["image_format"] == "png" def test_ai_render_product_surface_uses_surface_uuid(self, mock_api: respx.MockRouter) -> None: - route = mock_api.post("/api/v1/sudoai/2d-mockups/2d-mockup-001/render").mock( + route = mock_api.post("/api/v1/photo-mockups/2d-mockup-001/render").mock( return_value=httpx.Response(200, json=MOCK_AI_RENDER_RESPONSE) ) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - client.ai.render( + client.photo_mockups.render( mockup_uuid="2d-mockup-001", print_areas=[{"surface_uuid": "surface-1", "color": "#FF0000"}], ) @@ -391,11 +391,11 @@ def test_ai_render_product_surface_uses_surface_uuid(self, mock_api: respx.MockR assert surface == {"surface_uuid": "surface-1", "color": "#FF0000"} def test_ai_render_with_remove_background(self, mock_api: respx.MockRouter) -> None: - route = mock_api.post("/api/v1/sudoai/2d-mockups/2d-mockup-001/render").mock( + route = mock_api.post("/api/v1/photo-mockups/2d-mockup-001/render").mock( return_value=httpx.Response(200, json=MOCK_AI_RENDER_RESPONSE) ) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - client.ai.render( + client.photo_mockups.render( mockup_uuid="2d-mockup-001", print_areas=[ { @@ -410,11 +410,11 @@ def test_ai_render_with_remove_background(self, mock_api: respx.MockRouter) -> N assert body["print_areas"][0]["remove_background"] is True def test_ai_render_async(self, mock_api: respx.MockRouter) -> None: - route = mock_api.post("/api/v1/sudoai/2d-mockups/2d-mockup-001/render").mock( + route = mock_api.post("/api/v1/photo-mockups/2d-mockup-001/render").mock( return_value=httpx.Response(202, json=MOCK_AI_RENDER_JOB_ACCEPTED_RESPONSE) ) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - result = client.ai.render( + result = client.photo_mockups.render( mockup_uuid="2d-mockup-001", print_areas=[{"uuid": "pa-1", "artwork_url": "https://example.com/design.png"}], is_async=True, @@ -423,7 +423,7 @@ def test_ai_render_async(self, mock_api: respx.MockRouter) -> None: # Async submit: 202 returns a JobAccepted (job envelope), NOT an AIRender. assert isinstance(result, JobAccepted) assert result.job_id == "2d-render-job-001" - assert result.kind == "2d_render" + assert result.kind == "photo_mockup_render" assert result.status == "queued" assert result.status_url == "/api/v1/jobs/2d-render-job-001" @@ -434,11 +434,11 @@ def test_ai_render_async(self, mock_api: respx.MockRouter) -> None: assert "mockup_uuid" not in body def test_ai_create(self, mock_api: respx.MockRouter) -> None: - route = mock_api.post("/api/v1/sudoai/2d-mockups").mock( + route = mock_api.post("/api/v1/photo-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: - result = client.ai.create( + result = client.photo_mockups.create( source_url="https://example.com/product.jpg", name="Product Front", idempotency_key="create-2d-001", @@ -460,18 +460,18 @@ def test_ai_create(self, mock_api: respx.MockRouter) -> None: assert route.calls.last.request.headers["x-api-key"] == TEST_API_KEY def test_ai_create_async(self, mock_api: respx.MockRouter) -> None: - route = mock_api.post("/api/v1/sudoai/2d-mockups").mock( + route = mock_api.post("/api/v1/photo-mockups").mock( return_value=httpx.Response(202, json=MOCK_2D_MOCKUP_JOB_ACCEPTED_RESPONSE) ) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - result = client.ai.create( + result = client.photo_mockups.create( source_url="https://example.com/product.jpg", is_async=True, ) assert isinstance(result, JobAccepted) assert result.job_id == "2d-create-job-001" - assert result.kind == "2d_create" + assert result.kind == "photo_mockup_create" assert result.status == "queued" assert result.status_url == "/api/v1/jobs/2d-create-job-001" assert json.loads(route.calls.last.request.content) == { @@ -482,7 +482,7 @@ def test_ai_create_async(self, mock_api: respx.MockRouter) -> None: def test_ai_create_base64_generates_stable_idempotency_key( self, mock_api: respx.MockRouter ) -> None: - route = mock_api.post("/api/v1/sudoai/2d-mockups") + route = mock_api.post("/api/v1/photo-mockups") route.side_effect = [ httpx.Response(500, json=ERROR_500), httpx.Response(202, json=MOCK_2D_MOCKUP_JOB_ACCEPTED_RESPONSE), @@ -493,7 +493,7 @@ def test_ai_create_base64_generates_stable_idempotency_key( base_url=TEST_BASE_URL, max_retries=2, ) as client: - client.ai.create(source_base64="aW1hZ2U=") + client.photo_mockups.create(source_base64="aW1hZ2U=") assert json.loads(route.calls.last.request.content) == {"source_base64": "aW1hZ2U="} keys = [call.request.headers["idempotency-key"] for call in route.calls] @@ -502,24 +502,24 @@ def test_ai_create_base64_generates_stable_idempotency_key( assert UUID(keys[0]).version == 4 def test_ai_create_insufficient_credits(self, mock_api: respx.MockRouter) -> None: - route = mock_api.post("/api/v1/sudoai/2d-mockups").mock( + route = mock_api.post("/api/v1/photo-mockups").mock( return_value=httpx.Response(402, json=ERROR_402) ) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: with pytest.raises(InsufficientCreditsError): - client.ai.create(source_url="https://example.com/product.jpg") + client.photo_mockups.create(source_url="https://example.com/product.jpg") assert len(route.calls) == 1 def test_ai_create_requires_exactly_one_source(self, mock_api: respx.MockRouter) -> None: with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: with pytest.raises(ValueError, match="exactly one"): - client.ai.create() + client.photo_mockups.create() with pytest.raises(ValueError, match="exactly one"): - client.ai.create(source_url="") + client.photo_mockups.create(source_url="") with pytest.raises(ValueError, match="exactly one"): - client.ai.create( + client.photo_mockups.create( source_url="https://example.com/product.jpg", source_base64="aW1hZ2U=", ) @@ -532,12 +532,12 @@ def test_ai_wait_for_2d_mockup_success(self, mock_api: respx.MockRouter) -> None httpx.Response(200, json=MOCK_2D_MOCKUP_JOB_QUEUED_RESPONSE), httpx.Response(200, json=MOCK_2D_MOCKUP_JOB_SUCCEEDED_RESPONSE), ] - detail_route = mock_api.get("/api/v1/sudoai/2d-mockups/2d-mockup-001").mock( + detail_route = mock_api.get("/api/v1/photo-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( + result = client.photo_mockups.wait_for_2d_mockup( "2d-create-job-001", poll_interval=0.0, ) @@ -563,22 +563,22 @@ 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 a photo-mockup create job"): - client.ai.wait_for_2d_mockup("video-job-001", poll_interval=0.0) + client.photo_mockups.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``.""" + def test_ai_wait_for_2d_mockup_accepts_earlier_kind(self, mock_api: respx.MockRouter) -> None: + """A job stamped with the earlier ``2d_create`` kind is the same work.""" 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"}, + json={**MOCK_2D_MOCKUP_JOB_SUCCEEDED_RESPONSE, "kind": "2d_create"}, ) ) - detail_route = mock_api.get("/api/v1/sudoai/2d-mockups/2d-mockup-001").mock( + detail_route = mock_api.get("/api/v1/photo-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) + result = client.photo_mockups.wait_for_2d_mockup("2d-create-job-001", poll_interval=0.0) assert isinstance(result, TwoDMockup) assert result.mockup_id == "2d-mockup-001" @@ -595,7 +595,7 @@ def test_ai_wait_for_2d_mockup_missing_mockup_uuid(self, mock_api: respx.MockRou with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: with pytest.raises(SudoMockError, match="without a mockup UUID"): - client.ai.wait_for_2d_mockup("2d-create-job-001", poll_interval=0.0) + client.photo_mockups.wait_for_2d_mockup("2d-create-job-001", poll_interval=0.0) def test_ai_wait_for_2d_mockup_failure(self, mock_api: respx.MockRouter) -> None: mock_api.get("/api/v1/jobs/2d-create-job-001").mock( @@ -604,7 +604,7 @@ def test_ai_wait_for_2d_mockup_failure(self, mock_api: respx.MockRouter) -> None with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: with pytest.raises(JobFailedError) as exc_info: - client.ai.wait_for_2d_mockup("2d-create-job-001", poll_interval=0.0) + client.photo_mockups.wait_for_2d_mockup("2d-create-job-001", poll_interval=0.0) assert exc_info.value.job_id == "2d-create-job-001" assert exc_info.value.error_code == "NOT_MOCKUPABLE" @@ -617,7 +617,7 @@ def test_ai_wait_for_2d_mockup_timeout(self, mock_api: respx.MockRouter) -> None with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: with pytest.raises(JobTimeoutError) as exc_info: - client.ai.wait_for_2d_mockup( + client.photo_mockups.wait_for_2d_mockup( "2d-create-job-001", poll_interval=0.0, timeout=0.0, @@ -626,13 +626,13 @@ def test_ai_wait_for_2d_mockup_timeout(self, mock_api: respx.MockRouter) -> None assert exc_info.value.job_id == "2d-create-job-001" def test_ai_update_2d_print_areas(self, mock_api: respx.MockRouter) -> None: - route = mock_api.put("/api/v1/sudoai/2d-mockups/2d-mockup-001/print-areas").mock( + route = mock_api.put("/api/v1/photo-mockups/2d-mockup-001/print-areas").mock( return_value=httpx.Response(200, json=MOCK_2D_PRINT_AREAS_UPDATE_RESPONSE) ) print_areas = [{"points": [[100, 100], [500, 100], [500, 500], [100, 500]]}] with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - result = client.ai.update_2d_print_areas("2d-mockup-001", print_areas) + result = client.photo_mockups.update_2d_print_areas("2d-mockup-001", print_areas) assert json.loads(route.calls.last.request.content) == {"print_areas": print_areas} assert isinstance(result, TwoDPrintAreasUpdate) @@ -643,7 +643,7 @@ def test_ai_update_2d_print_areas(self, mock_api: respx.MockRouter) -> None: def test_ai_update_2d_print_areas_forwards_empty_product_surface_state( self, mock_api: respx.MockRouter ) -> None: - route = mock_api.put("/api/v1/sudoai/2d-mockups/2d-mockup-001/print-areas").mock( + route = mock_api.put("/api/v1/photo-mockups/2d-mockup-001/print-areas").mock( return_value=httpx.Response( 200, json={ @@ -654,30 +654,30 @@ def test_ai_update_2d_print_areas_forwards_empty_product_surface_state( ) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - result = client.ai.update_2d_print_areas("2d-mockup-001", []) + result = client.photo_mockups.update_2d_print_areas("2d-mockup-001", []) assert json.loads(route.calls.last.request.content) == {"print_areas": []} assert result.print_areas == [] def test_ai_list_get_delete(self, mock_api: respx.MockRouter) -> None: - mock_api.get("/api/v1/sudoai/2d-mockups").mock( + mock_api.get("/api/v1/photo-mockups").mock( return_value=httpx.Response(200, json=MOCK_2D_MOCKUP_LIST_RESPONSE) ) - mock_api.get("/api/v1/sudoai/2d-mockups/2d-mockup-001").mock( + mock_api.get("/api/v1/photo-mockups/2d-mockup-001").mock( return_value=httpx.Response(200, json=MOCK_2D_MOCKUP_GET_RESPONSE) ) - mock_api.delete("/api/v1/sudoai/2d-mockups/2d-mockup-001").mock( + mock_api.delete("/api/v1/photo-mockups/2d-mockup-001").mock( return_value=httpx.Response(200, json=MOCK_2D_MOCKUP_DELETE_RESPONSE) ) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - listing = client.ai.list(limit=20, customizable_only=True) + listing = client.photo_mockups.list(limit=20, customizable_only=True) assert listing.total == 1 assert listing.mockups[0].mockup_id == "2d-mockup-001" assert listing.mockups[0].customizable is True assert listing.mockups[0].print_areas[0].print_area_id == "pa-1" assert listing.mockups[0].surfaces[0].surface_uuid == "surface-1" - one = client.ai.get("2d-mockup-001") + one = client.photo_mockups.get("2d-mockup-001") assert one.mockup_id == "2d-mockup-001" assert one.name == "Flat Tee Front" assert one.customizable is True @@ -685,7 +685,7 @@ def test_ai_list_get_delete(self, mock_api: respx.MockRouter) -> None: # model drops it rather than passing it on. assert one.surfaces[0].model_dump() == {"surface_uuid": "surface-1"} - client.ai.delete("2d-mockup-001") # should not raise + client.photo_mockups.delete("2d-mockup-001") # should not raise assert mock_api.calls[0].request.url.params["customizable_only"] == "true" @@ -910,25 +910,25 @@ def test_get_account(self, mock_api: respx.MockRouter) -> None: class TestErrorHandling: def test_401_raises_auth_error(self, mock_api: respx.MockRouter) -> None: - mock_api.get("/api/v1/mockups").mock(return_value=httpx.Response(401, json=ERROR_401)) + mock_api.get("/api/v1/psd-mockups").mock(return_value=httpx.Response(401, json=ERROR_401)) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: with pytest.raises(AuthenticationError) as exc_info: - client.mockups.list() + client.psd_mockups.list() assert exc_info.value.status_code == 401 def test_402_raises_credit_error(self, mock_api: respx.MockRouter) -> None: - mock_api.get("/api/v1/mockups").mock(return_value=httpx.Response(402, json=ERROR_402)) + mock_api.get("/api/v1/psd-mockups").mock(return_value=httpx.Response(402, json=ERROR_402)) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: with pytest.raises(InsufficientCreditsError): - client.mockups.list() + client.psd_mockups.list() def test_404_raises_not_found(self, mock_api: respx.MockRouter) -> None: - mock_api.get("/api/v1/mockups/missing").mock( + mock_api.get("/api/v1/psd-mockups/missing").mock( return_value=httpx.Response(404, json=ERROR_404) ) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: with pytest.raises(NotFoundError): - client.mockups.get("missing") + client.psd_mockups.get("missing") def test_422_raises_validation_error(self, mock_api: respx.MockRouter) -> None: mock_api.post("/api/v1/renders").mock(return_value=httpx.Response(422, json=ERROR_422)) @@ -940,17 +940,17 @@ def test_422_raises_validation_error(self, mock_api: respx.MockRouter) -> None: ) def test_429_raises_rate_limit_error(self, mock_api: respx.MockRouter) -> None: - mock_api.get("/api/v1/mockups").mock(return_value=httpx.Response(429, json=ERROR_429)) + mock_api.get("/api/v1/psd-mockups").mock(return_value=httpx.Response(429, json=ERROR_429)) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: with pytest.raises(RateLimitError) as exc_info: - client.mockups.list() + client.psd_mockups.list() assert exc_info.value.retry_after == 30 def test_500_raises_server_error(self, mock_api: respx.MockRouter) -> None: - mock_api.get("/api/v1/mockups").mock(return_value=httpx.Response(500, json=ERROR_500)) + mock_api.get("/api/v1/psd-mockups").mock(return_value=httpx.Response(500, json=ERROR_500)) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: with pytest.raises(ServerError) as exc_info: - client.mockups.list() + client.psd_mockups.list() assert exc_info.value.status_code == 500 def test_all_errors_inherit_base(self) -> None: @@ -968,10 +968,10 @@ def test_all_errors_inherit_base(self) -> None: assert issubclass(exc_cls, SudoMockError) def test_error_contains_body(self, mock_api: respx.MockRouter) -> None: - mock_api.get("/api/v1/mockups").mock(return_value=httpx.Response(500, json=ERROR_500)) + mock_api.get("/api/v1/psd-mockups").mock(return_value=httpx.Response(500, json=ERROR_500)) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: with pytest.raises(ServerError) as exc_info: - client.mockups.list() + client.psd_mockups.list() assert exc_info.value.body is not None assert ( exc_info.value.body["detail"] @@ -987,43 +987,43 @@ def test_error_contains_body(self, mock_api: respx.MockRouter) -> None: class TestRetry: def test_retries_on_500(self, mock_api: respx.MockRouter) -> None: """Server errors should be retried (up to max_retries).""" - route = mock_api.get("/api/v1/mockups") + route = mock_api.get("/api/v1/psd-mockups") route.side_effect = [ httpx.Response(500, json=ERROR_500), httpx.Response(500, json=ERROR_500), httpx.Response(200, json=MOCK_MOCKUP_LIST_RESPONSE), ] with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL, max_retries=3) as client: - result = client.mockups.list() + result = client.psd_mockups.list() assert result.total == 1 assert len(route.calls) == 3 def test_retries_on_429(self, mock_api: respx.MockRouter) -> None: """Rate limit errors should be retried.""" - route = mock_api.get("/api/v1/mockups") + route = mock_api.get("/api/v1/psd-mockups") route.side_effect = [ httpx.Response(429, json=ERROR_429), httpx.Response(200, json=MOCK_MOCKUP_LIST_RESPONSE), ] with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL, max_retries=2) as client: - result = client.mockups.list() + result = client.psd_mockups.list() assert result.total == 1 def test_no_retry_on_4xx(self, mock_api: respx.MockRouter) -> None: """Client errors (except 429) should NOT be retried.""" - route = mock_api.get("/api/v1/mockups") + route = mock_api.get("/api/v1/psd-mockups") route.mock(return_value=httpx.Response(401, json=ERROR_401)) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL, max_retries=3) as client: with pytest.raises(AuthenticationError): - client.mockups.list() + client.psd_mockups.list() assert len(route.calls) == 1 def test_retries_exhausted(self, mock_api: respx.MockRouter) -> None: """After all retries exhausted, the last error should be raised.""" - route = mock_api.get("/api/v1/mockups") + route = mock_api.get("/api/v1/psd-mockups") route.side_effect = [ httpx.Response(500, json=ERROR_500), httpx.Response(500, json=ERROR_500), @@ -1031,7 +1031,7 @@ def test_retries_exhausted(self, mock_api: respx.MockRouter) -> None: ] with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL, max_retries=3) as client: with pytest.raises(ServerError): - client.mockups.list() + client.psd_mockups.list() # --------------------------------------------------------------------------- @@ -1041,11 +1041,11 @@ def test_retries_exhausted(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( + route = mock_api.get("/api/v1/psd-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() + client.psd_mockups.list() headers = route.calls.last.request.headers expected = f"python-sdk/{sudomock.__version__}" @@ -1055,11 +1055,11 @@ def test_client_identity_headers(self, mock_api: respx.MockRouter) -> None: 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( + route = mock_api.post("/api/v1/photo-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( + client.photo_mockups.create( source_url="https://example.com/product.jpg", idempotency_key="idem-123", ) diff --git a/tests/test_family_paths.py b/tests/test_family_paths.py new file mode 100644 index 0000000..8a0b683 --- /dev/null +++ b/tests/test_family_paths.py @@ -0,0 +1,180 @@ +"""Family paths and helper names (0.11.0). + +``client.photo_mockups`` talks to ``/api/v1/photo-mockups`` and +``client.psd_mockups`` to ``/api/v1/psd-mockups``. ``client.ai`` and +``client.mockups`` keep working as deprecated aliases of those same objects, +and the ``PhotoMockup*`` model names are the same classes as the older +``TwoDMockup*`` / ``AIRender`` names. +""" + +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING + +import httpx +import pytest + +import sudomock +from sudomock import AsyncSudoMock, SudoMock +from sudomock.models import ( + AIRender, + PhotoMockup, + PhotoMockupList, + PhotoMockupPrintAreasUpdate, + PhotoMockupRender, + TwoDMockup, + TwoDMockupList, + TwoDPrintAreasUpdate, +) + +from .conftest import ( + MOCK_2D_MOCKUP_CREATE_RESPONSE, + MOCK_2D_MOCKUP_LIST_RESPONSE, + MOCK_MOCKUP_GET_RESPONSE, + MOCK_MOCKUP_LIST_RESPONSE, + TEST_API_KEY, + TEST_BASE_URL, +) + +if TYPE_CHECKING: + import respx + +PSD_UUID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + +# --------------------------------------------------------------------------- +# Wire paths +# --------------------------------------------------------------------------- + + +class TestFamilyPaths: + def test_photo_mockups_create_posts_to_family_path(self, mock_api: respx.MockRouter) -> None: + route = mock_api.post("/api/v1/photo-mockups").mock( + return_value=httpx.Response(201, json=MOCK_2D_MOCKUP_CREATE_RESPONSE) + ) + with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + result = client.photo_mockups.create(source_url="https://example.com/p.jpg") + + assert route.called + assert isinstance(result, PhotoMockup) + + def test_psd_mockups_get_uses_family_path(self, mock_api: respx.MockRouter) -> None: + route = mock_api.get(f"/api/v1/psd-mockups/{PSD_UUID}").mock( + return_value=httpx.Response(200, json=MOCK_MOCKUP_GET_RESPONSE) + ) + with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + result = client.psd_mockups.get(PSD_UUID) + + assert route.called + assert result.uuid == PSD_UUID + + def test_new_names_do_not_warn(self, mock_api: respx.MockRouter) -> None: + mock_api.get("/api/v1/photo-mockups").mock( + return_value=httpx.Response(200, json=MOCK_2D_MOCKUP_LIST_RESPONSE) + ) + mock_api.get("/api/v1/psd-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: + with warnings.catch_warnings(): + warnings.simplefilter("error") + client.photo_mockups.list() + client.psd_mockups.list() + + async def test_async_photo_mockups_list_uses_family_path( + self, mock_api: respx.MockRouter + ) -> None: + route = mock_api.get("/api/v1/photo-mockups").mock( + return_value=httpx.Response(200, json=MOCK_2D_MOCKUP_LIST_RESPONSE) + ) + async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + listing = await client.photo_mockups.list() + + assert route.called + assert isinstance(listing, PhotoMockupList) + + async def test_async_psd_mockups_list_uses_family_path( + self, mock_api: respx.MockRouter + ) -> None: + route = mock_api.get("/api/v1/psd-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: + listing = await client.psd_mockups.list() + + assert route.called + assert listing.total == 1 + + +# --------------------------------------------------------------------------- +# Deprecated helper names +# --------------------------------------------------------------------------- + + +class TestDeprecatedAliases: + def test_sync_ai_is_photo_mockups(self) -> None: + with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + with pytest.warns(DeprecationWarning, match="photo_mockups"): + assert client.ai is client.photo_mockups + + def test_sync_mockups_is_psd_mockups(self) -> None: + with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + with pytest.warns(DeprecationWarning, match="psd_mockups"): + assert client.mockups is client.psd_mockups + + async def test_async_ai_is_photo_mockups(self) -> None: + async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + with pytest.warns(DeprecationWarning, match="photo_mockups"): + assert client.ai is client.photo_mockups + + async def test_async_mockups_is_psd_mockups(self) -> None: + async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + with pytest.warns(DeprecationWarning, match="psd_mockups"): + assert client.mockups is client.psd_mockups + + def test_alias_still_reaches_family_path(self, mock_api: respx.MockRouter) -> None: + route = mock_api.get("/api/v1/photo-mockups").mock( + return_value=httpx.Response(200, json=MOCK_2D_MOCKUP_LIST_RESPONSE) + ) + with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + with pytest.warns(DeprecationWarning): + client.ai.list() + + assert route.called + + +# --------------------------------------------------------------------------- +# Model name aliases +# --------------------------------------------------------------------------- + + +class TestModelAliases: + @pytest.mark.parametrize( + ("new", "old"), + [ + (PhotoMockup, TwoDMockup), + (PhotoMockupList, TwoDMockupList), + (PhotoMockupPrintAreasUpdate, TwoDPrintAreasUpdate), + (PhotoMockupRender, AIRender), + ], + ) + def test_same_class(self, new: type, old: type) -> None: + assert new is old + + @pytest.mark.parametrize( + "name", + [ + "PhotoMockup", + "PhotoMockupList", + "PhotoMockupPrintAreasUpdate", + "PhotoMockupRender", + "TwoDMockup", + "TwoDMockupList", + "TwoDPrintAreasUpdate", + "AIRender", + ], + ) + def test_exported(self, name: str) -> None: + assert name in sudomock.__all__ + assert getattr(sudomock, name) is getattr(sudomock.models, name) diff --git a/tests/test_legacy_assignment.py b/tests/test_legacy_assignment.py new file mode 100644 index 0000000..d1490fb --- /dev/null +++ b/tests/test_legacy_assignment.py @@ -0,0 +1,31 @@ +"""Earlier accessor names stay assignable so existing test doubles keep working.""" + +import sudomock + + +class _Stub: + def __init__(self): + self.calls = [] + + +def test_legacy_ai_assignment_still_works(): + c = sudomock.SudoMock(api_key="sm_test") + stub = _Stub() + c.ai = stub # must not raise + assert c.photo_mockups is stub + + +def test_legacy_mockups_assignment_still_works(): + c = sudomock.SudoMock(api_key="sm_test") + stub = _Stub() + c.mockups = stub + assert c.psd_mockups is stub + + +def test_async_legacy_assignment_still_works(): + c = sudomock.AsyncSudoMock(api_key="sm_test") + stub = _Stub() + c.ai = stub + assert c.photo_mockups is stub + c.mockups = stub + assert c.psd_mockups is stub