diff --git a/CHANGELOG.md b/CHANGELOG.md index a82c76d..8339634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.11.1] - 2026-09-19 + +### Fixed +- **`client.ai` and `client.mockups` call the endpoints they have always + called.** In 0.11.0 both accessors were aliases of `client.photo_mockups` / + `client.psd_mockups` and so began calling `/api/v1/photo-mockups` and + `/api/v1/psd-mockups`. Code that upgraded the SDK without being rewritten + therefore opened its jobs on the current path and received the current job + kinds (`photo_mockup_create` / `photo_mockup_render`) where it had always + received `2d_create` / `2d_render` — a branch on `kind` stopped matching + without raising anything. `client.ai` is pinned back to + `/api/v1/sudoai/2d-mockups` and `client.mockups` to `/api/v1/mockups`, the + paths their callers were already using and that the API keeps serving, so + upgrading to this release changes nothing for code written against the + earlier names. `client.photo_mockups` and `client.psd_mockups` are unchanged + and keep calling the current paths; move an accessor over when you are ready + to read the current job kinds. The 0.11.0 note that an SDK had to stay pinned + below 0.11.0 to keep the earlier paths no longer applies. +- Assigning to `client.ai` or `client.mockups` reaches the earlier accessor, so + a test double injected under either name is the object that gets called. +- **Assigning to `client.ai` or `client.mockups` no longer moves + `client.photo_mockups` / `client.psd_mockups`.** The setter wrote both + accessors while the getter read only the earlier one, so the ordinary + save-and-restore pattern around a test double (`saved = client.ai` ... + `client.ai = saved`, or `mock.patch.object`) left the current accessor + pointing at the earlier endpoint for the rest of the process — every later + `client.photo_mockups` call went to `/api/v1/sudoai/2d-mockups` and came back + with the earlier job kinds, with nothing raised. Each earlier name now reads + back exactly what was written to it and leaves the current name alone, so a + restore puts both accessors where they started. +- **`del client.ai` and `del client.mockups` work.** The earlier names had no + deleter, so `mock.patch.object(client, "ai", double)` raised + `AttributeError: property 'ai' of 'SudoMock' object has no deleter` when its + block ended. Deleting an earlier name drops the override and restores the + accessor the client was built with, still pinned to the endpoint that name + has always called. + +### Changed +- `webhook_endpoints.create(...)` docs: an endpoint that does not pass + `event_naming` is pinned by the spelling of its `events` — the earlier event + names pin it to `"legacy"`, the current names to `"current"`, an empty or + mixed list to `"legacy"`. The earlier note that the API always pinned a new + endpoint to `"current"` no longer describes what happens. Passing + `event_naming` explicitly decides it and is unaffected. + + ## [0.11.0] - 2026-09-19 ### Added diff --git a/README.md b/README.md index 60230e7..a948b68 100644 --- a/README.md +++ b/README.md @@ -543,7 +543,7 @@ client = SudoMock( | Method | Description | |--------|-------------| | `client.webhook_endpoints.list()` | List registered endpoints | -| `client.webhook_endpoints.create(url=, events=, description=None, event_naming=None)` | Register an endpoint (empty `events` = all; `event_naming` `"current"` / `"legacy"`, API default `"current"`) | +| `client.webhook_endpoints.create(url=, events=, description=None, event_naming=None)` | Register an endpoint (empty `events` = all; `event_naming` `"current"` / `"legacy"`; left out, the API follows the spelling of `events`) | | `client.webhook_endpoints.get(uuid)` | Get an endpoint | | `client.webhook_endpoints.update(uuid, url=, events=, description=, enabled=, event_naming=)` | Update an endpoint (`event_naming` re-pins it to `"current"` or `"legacy"`) | | `client.webhook_endpoints.delete(uuid)` | Delete an endpoint | diff --git a/pyproject.toml b/pyproject.toml index 57f2b94..ffe7712 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sudomock" -version = "0.11.0" +version = "0.11.1" description = "Official Python SDK for the SudoMock Mockup Generator API" readme = "README.md" license = "MIT" diff --git a/src/sudomock/_http.py b/src/sudomock/_http.py index 5f8c5a6..d12ec53 100644 --- a/src/sudomock/_http.py +++ b/src/sudomock/_http.py @@ -44,6 +44,16 @@ DEFAULT_RENDER_TIMEOUT = 120.0 DEFAULT_MAX_RETRIES = 3 +# Endpoints the current accessors (``photo_mockups`` / ``psd_mockups``) call. +PHOTO_MOCKUPS_PATH = "/api/v1/photo-mockups" +PSD_MOCKUPS_PATH = "/api/v1/psd-mockups" + +# Endpoints the earlier accessors (``ai`` / ``mockups``) have always called and +# keep calling. Code written against those names was never rewritten, so the +# request it produces must not change either. Both remain served by the API. +EARLIER_PHOTO_MOCKUPS_PATH = "/api/v1/sudoai/2d-mockups" +EARLIER_PSD_MOCKUPS_PATH = "/api/v1/mockups" + # --------------------------------------------------------------------------- # Error mapping diff --git a/src/sudomock/async_client.py b/src/sudomock/async_client.py index 916603b..dea2f35 100644 --- a/src/sudomock/async_client.py +++ b/src/sudomock/async_client.py @@ -43,6 +43,10 @@ DEFAULT_MAX_RETRIES, DEFAULT_RENDER_TIMEOUT, DEFAULT_TIMEOUT, + EARLIER_PHOTO_MOCKUPS_PATH, + EARLIER_PSD_MOCKUPS_PATH, + PHOTO_MOCKUPS_PATH, + PSD_MOCKUPS_PATH, AsyncTransport, ) from ._public_contract import public_2d_render_targets @@ -85,8 +89,9 @@ class _AsyncPsdMockupsResource: """Async PSD mockup template operations (list, get, update, delete).""" - def __init__(self, transport: AsyncTransport) -> None: + def __init__(self, transport: AsyncTransport, base: str = PSD_MOCKUPS_PATH) -> None: self._transport = transport + self._base = base async def list( self, @@ -116,7 +121,7 @@ async def list( """ resp = await self._transport.request( "GET", - "/api/v1/psd-mockups", + self._base, params={ "limit": limit, "offset": offset, @@ -142,7 +147,7 @@ async def get(self, uuid: str) -> Mockup: Raises: NotFoundError: If the mockup does not exist. """ - resp = await self._transport.request("GET", f"/api/v1/psd-mockups/{uuid}") + resp = await self._transport.request("GET", f"{self._base}/{uuid}") payload = resp.json() return Mockup.model_validate({**payload["data"], "warnings": payload.get("warnings") or []}) @@ -159,9 +164,7 @@ async def update(self, uuid: str, *, name: str) -> Mockup: Raises: NotFoundError: If the mockup does not exist. """ - resp = await self._transport.request( - "PATCH", f"/api/v1/psd-mockups/{uuid}", json={"name": name} - ) + resp = await self._transport.request("PATCH", f"{self._base}/{uuid}", json={"name": name}) payload = resp.json() return Mockup.model_validate({**payload["data"], "warnings": payload.get("warnings") or []}) @@ -174,7 +177,7 @@ async def delete(self, uuid: str) -> None: Raises: NotFoundError: If the mockup does not exist. """ - await self._transport.request("DELETE", f"/api/v1/psd-mockups/{uuid}") + await self._transport.request("DELETE", f"{self._base}/{uuid}") class _AsyncRendersResource: @@ -481,9 +484,11 @@ async def create( event_naming: Which spelling of the photo-mockup events this endpoint receives: ``"current"`` (``photo_mockup.*``, ``photo_mockup_render.*``) or ``"legacy"`` (``2d_mockup.*``, - ``2d_render.*``). Left out, the API pins a new endpoint to - ``"current"``; pass ``"legacy"`` for a handler that still reads - the older names. + ``2d_render.*``). Left out, the API reads the spelling of + ``events``: a list written in the earlier names pins the + endpoint to ``"legacy"``, a list written in the current names + pins it to ``"current"``, and an empty or mixed list pins it to + ``"legacy"``. Pass the value outright to decide it yourself. """ # API field is `event_types` (empty list = subscribe to all events). # NOTE: the create endpoint has no `enabled` field (it is update-only). @@ -601,8 +606,9 @@ async def replay_failed(self, uuid: str) -> None: class _AsyncPhotoMockupsResource: """Async photo mockup operations (create, render, list, get, delete, print areas).""" - def __init__(self, transport: AsyncTransport) -> None: + def __init__(self, transport: AsyncTransport, base: str = PHOTO_MOCKUPS_PATH) -> None: self._transport = transport + self._base = base async def create( self, @@ -658,7 +664,7 @@ async def create( resp = await self._transport.request( "POST", - "/api/v1/photo-mockups", + self._base, json=body, headers={"Idempotency-Key": idempotency_key}, ) @@ -722,7 +728,7 @@ async def update_2d_print_areas( """ resp = await self._transport.request( "PUT", - f"/api/v1/photo-mockups/{mockup_id}/print-areas", + f"{self._base}/{mockup_id}/print-areas", json={"print_areas": print_areas}, ) return PhotoMockupPrintAreasUpdate.model_validate(resp.json()["data"]) @@ -793,7 +799,7 @@ async def render( resp = await self._transport.request( "POST", - f"/api/v1/photo-mockups/{mockup_uuid}/render", + f"{self._base}/{mockup_uuid}/render", json=body, timeout=self._transport._render_timeout, ) @@ -814,7 +820,7 @@ async def list( """List your photo mockups (free; zero credits).""" resp = await self._transport.request( "GET", - "/api/v1/photo-mockups", + self._base, params={ "limit": limit, "offset": offset, @@ -835,7 +841,7 @@ async def get(self, mockup_id: str) -> PhotoMockup: Raises: NotFoundError: If the photo mockup does not exist. """ - resp = await self._transport.request("GET", f"/api/v1/photo-mockups/{mockup_id}") + resp = await self._transport.request("GET", f"{self._base}/{mockup_id}") return PhotoMockup.model_validate(resp.json()["data"]) async def delete(self, mockup_id: str) -> None: @@ -844,7 +850,7 @@ async def delete(self, mockup_id: str) -> None: Raises: NotFoundError: If the photo mockup does not exist. """ - await self._transport.request("DELETE", f"/api/v1/photo-mockups/{mockup_id}") + await self._transport.request("DELETE", f"{self._base}/{mockup_id}") class _AsyncImagesResource: @@ -1077,35 +1083,92 @@ def __init__( self.packages = _AsyncPackagesResource(self._transport) self.webhook_endpoints = _AsyncWebhookEndpointsResource(self._transport) + # Same resources, pinned to the endpoints the earlier accessors have + # always called. Kept as their own objects so ``photo_mockups`` and + # ``psd_mockups`` can move on without moving the earlier callers. + self._earlier_photo_mockups = _AsyncPhotoMockupsResource( + self._transport, base=EARLIER_PHOTO_MOCKUPS_PATH + ) + self._earlier_psd_mockups = _AsyncPsdMockupsResource( + self._transport, base=EARLIER_PSD_MOCKUPS_PATH + ) + @property def ai(self) -> _AsyncPhotoMockupsResource: - """Earlier name of :attr:`photo_mockups`; emits a ``DeprecationWarning``.""" + """Earlier name of :attr:`photo_mockups`; emits a ``DeprecationWarning``. + + Calls made through this name keep going to the endpoint they have + always gone to, so a job opened here is the job this caller has always + opened. Use :attr:`photo_mockups` for the current endpoint. + """ warnings.warn( "AsyncSudoMock.ai is deprecated; use AsyncSudoMock.photo_mockups instead.", DeprecationWarning, stacklevel=2, ) - return self.photo_mockups + return self._earlier_photo_mockups @ai.setter def ai(self, value: object) -> None: - """Assignment still works so existing test doubles keep running.""" - self.photo_mockups = value # type: ignore[assignment] + """Assignment still works so existing test doubles keep running. + + What is written here is what this name reads back, and only this name: + :attr:`photo_mockups` is left where it is. A caller that saves this + accessor, swaps in a double and writes the saved value back therefore + puts both accessors exactly where they started. + """ + self._earlier_photo_mockups = value # type: ignore[assignment] + + @ai.deleter + def ai(self) -> None: + """``del client.ai`` drops the override and restores the default. + + The accessor goes back to the one the client was built with, still + pinned to the endpoint this name has always called. + ``mock.patch.object`` deletes the attribute when its block ends, so + this is the way back from a patched double. + """ + self._earlier_photo_mockups = _AsyncPhotoMockupsResource( + self._transport, base=EARLIER_PHOTO_MOCKUPS_PATH + ) @property def mockups(self) -> _AsyncPsdMockupsResource: - """Earlier name of :attr:`psd_mockups`; emits a ``DeprecationWarning``.""" + """Earlier name of :attr:`psd_mockups`; emits a ``DeprecationWarning``. + + Calls made through this name keep going to the endpoint they have + always gone to. Use :attr:`psd_mockups` for the current endpoint. + """ warnings.warn( "AsyncSudoMock.mockups is deprecated; use AsyncSudoMock.psd_mockups instead.", DeprecationWarning, stacklevel=2, ) - return self.psd_mockups + return self._earlier_psd_mockups @mockups.setter def mockups(self, value: object) -> None: - """Assignment still works so existing test doubles keep running.""" - self.psd_mockups = value # type: ignore[assignment] + """Assignment still works so existing test doubles keep running. + + What is written here is what this name reads back, and only this name: + :attr:`psd_mockups` is left where it is. A caller that saves this + accessor, swaps in a double and writes the saved value back therefore + puts both accessors exactly where they started. + """ + self._earlier_psd_mockups = value # type: ignore[assignment] + + @mockups.deleter + def mockups(self) -> None: + """``del client.mockups`` drops the override and restores the default. + + The accessor goes back to the one the client was built with, still + pinned to the endpoint this name has always called. + ``mock.patch.object`` deletes the attribute when its block ends, so + this is the way back from a patched double. + """ + self._earlier_psd_mockups = _AsyncPsdMockupsResource( + self._transport, base=EARLIER_PSD_MOCKUPS_PATH + ) async def close(self) -> None: """Close the underlying HTTP connection pool.""" diff --git a/src/sudomock/client.py b/src/sudomock/client.py index 23f7c39..df9d6f6 100644 --- a/src/sudomock/client.py +++ b/src/sudomock/client.py @@ -27,6 +27,10 @@ DEFAULT_MAX_RETRIES, DEFAULT_RENDER_TIMEOUT, DEFAULT_TIMEOUT, + EARLIER_PHOTO_MOCKUPS_PATH, + EARLIER_PSD_MOCKUPS_PATH, + PHOTO_MOCKUPS_PATH, + PSD_MOCKUPS_PATH, SyncTransport, ) from ._public_contract import public_2d_render_targets @@ -69,8 +73,9 @@ class _PsdMockupsResource: """PSD mockup template operations (list, get, update, delete).""" - def __init__(self, transport: SyncTransport) -> None: + def __init__(self, transport: SyncTransport, base: str = PSD_MOCKUPS_PATH) -> None: self._transport = transport + self._base = base def list( self, @@ -100,7 +105,7 @@ def list( """ resp = self._transport.request( "GET", - "/api/v1/psd-mockups", + self._base, params={ "limit": limit, "offset": offset, @@ -126,7 +131,7 @@ def get(self, uuid: str) -> Mockup: Raises: NotFoundError: If the mockup does not exist. """ - resp = self._transport.request("GET", f"/api/v1/psd-mockups/{uuid}") + resp = self._transport.request("GET", f"{self._base}/{uuid}") payload = resp.json() return Mockup.model_validate({**payload["data"], "warnings": payload.get("warnings") or []}) @@ -143,7 +148,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/psd-mockups/{uuid}", json={"name": name}) + resp = self._transport.request("PATCH", f"{self._base}/{uuid}", json={"name": name}) payload = resp.json() return Mockup.model_validate({**payload["data"], "warnings": payload.get("warnings") or []}) @@ -156,7 +161,7 @@ def delete(self, uuid: str) -> None: Raises: NotFoundError: If the mockup does not exist. """ - self._transport.request("DELETE", f"/api/v1/psd-mockups/{uuid}") + self._transport.request("DELETE", f"{self._base}/{uuid}") class _RendersResource: @@ -507,9 +512,11 @@ def create( event_naming: Which spelling of the photo-mockup events this endpoint receives: ``"current"`` (``photo_mockup.*``, ``photo_mockup_render.*``) or ``"legacy"`` (``2d_mockup.*``, - ``2d_render.*``). Left out, the API pins a new endpoint to - ``"current"``; pass ``"legacy"`` for a handler that still reads - the older names. + ``2d_render.*``). Left out, the API reads the spelling of + ``events``: a list written in the earlier names pins the + endpoint to ``"legacy"``, a list written in the current names + pins it to ``"current"``, and an empty or mixed list pins it to + ``"legacy"``. Pass the value outright to decide it yourself. Returns: The created :class:`WebhookEndpoint` (includes the signing @@ -640,8 +647,9 @@ def replay_failed(self, uuid: str) -> None: class _PhotoMockupsResource: """Photo mockup operations (create, render, list, get, delete, print areas).""" - def __init__(self, transport: SyncTransport) -> None: + def __init__(self, transport: SyncTransport, base: str = PHOTO_MOCKUPS_PATH) -> None: self._transport = transport + self._base = base def create( self, @@ -697,7 +705,7 @@ def create( resp = self._transport.request( "POST", - "/api/v1/photo-mockups", + self._base, json=body, headers={"Idempotency-Key": idempotency_key}, ) @@ -767,7 +775,7 @@ def update_2d_print_areas( """ resp = self._transport.request( "PUT", - f"/api/v1/photo-mockups/{mockup_id}/print-areas", + f"{self._base}/{mockup_id}/print-areas", json={"print_areas": print_areas}, ) return PhotoMockupPrintAreasUpdate.model_validate(resp.json()["data"]) @@ -838,7 +846,7 @@ def render( resp = self._transport.request( "POST", - f"/api/v1/photo-mockups/{mockup_uuid}/render", + f"{self._base}/{mockup_uuid}/render", json=body, timeout=self._transport._render_timeout, ) @@ -869,7 +877,7 @@ def list( """ resp = self._transport.request( "GET", - "/api/v1/photo-mockups", + self._base, params={ "limit": limit, "offset": offset, @@ -890,7 +898,7 @@ def get(self, mockup_id: str) -> PhotoMockup: Raises: NotFoundError: If the photo mockup does not exist. """ - resp = self._transport.request("GET", f"/api/v1/photo-mockups/{mockup_id}") + resp = self._transport.request("GET", f"{self._base}/{mockup_id}") return PhotoMockup.model_validate(resp.json()["data"]) def delete(self, mockup_id: str) -> None: @@ -899,7 +907,7 @@ def delete(self, mockup_id: str) -> None: Raises: NotFoundError: If the photo mockup does not exist. """ - self._transport.request("DELETE", f"/api/v1/photo-mockups/{mockup_id}") + self._transport.request("DELETE", f"{self._base}/{mockup_id}") class _ImagesResource: @@ -1138,35 +1146,92 @@ def __init__( self.packages = _PackagesResource(self._transport) self.webhook_endpoints = _WebhookEndpointsResource(self._transport) + # Same resources, pinned to the endpoints the earlier accessors have + # always called. Kept as their own objects so ``photo_mockups`` and + # ``psd_mockups`` can move on without moving the earlier callers. + self._earlier_photo_mockups = _PhotoMockupsResource( + self._transport, base=EARLIER_PHOTO_MOCKUPS_PATH + ) + self._earlier_psd_mockups = _PsdMockupsResource( + self._transport, base=EARLIER_PSD_MOCKUPS_PATH + ) + @property def ai(self) -> _PhotoMockupsResource: - """Earlier name of :attr:`photo_mockups`; emits a ``DeprecationWarning``.""" + """Earlier name of :attr:`photo_mockups`; emits a ``DeprecationWarning``. + + Calls made through this name keep going to the endpoint they have + always gone to, so a job opened here is the job this caller has always + opened. Use :attr:`photo_mockups` for the current endpoint. + """ warnings.warn( "SudoMock.ai is deprecated; use SudoMock.photo_mockups instead.", DeprecationWarning, stacklevel=2, ) - return self.photo_mockups + return self._earlier_photo_mockups @ai.setter def ai(self, value: object) -> None: - """Assignment still works so existing test doubles keep running.""" - self.photo_mockups = value # type: ignore[assignment] + """Assignment still works so existing test doubles keep running. + + What is written here is what this name reads back, and only this name: + :attr:`photo_mockups` is left where it is. A caller that saves this + accessor, swaps in a double and writes the saved value back therefore + puts both accessors exactly where they started. + """ + self._earlier_photo_mockups = value # type: ignore[assignment] + + @ai.deleter + def ai(self) -> None: + """``del client.ai`` drops the override and restores the default. + + The accessor goes back to the one the client was built with, still + pinned to the endpoint this name has always called. + ``mock.patch.object`` deletes the attribute when its block ends, so + this is the way back from a patched double. + """ + self._earlier_photo_mockups = _PhotoMockupsResource( + self._transport, base=EARLIER_PHOTO_MOCKUPS_PATH + ) @property def mockups(self) -> _PsdMockupsResource: - """Earlier name of :attr:`psd_mockups`; emits a ``DeprecationWarning``.""" + """Earlier name of :attr:`psd_mockups`; emits a ``DeprecationWarning``. + + Calls made through this name keep going to the endpoint they have + always gone to. Use :attr:`psd_mockups` for the current endpoint. + """ warnings.warn( "SudoMock.mockups is deprecated; use SudoMock.psd_mockups instead.", DeprecationWarning, stacklevel=2, ) - return self.psd_mockups + return self._earlier_psd_mockups @mockups.setter def mockups(self, value: object) -> None: - """Assignment still works so existing test doubles keep running.""" - self.psd_mockups = value # type: ignore[assignment] + """Assignment still works so existing test doubles keep running. + + What is written here is what this name reads back, and only this name: + :attr:`psd_mockups` is left where it is. A caller that saves this + accessor, swaps in a double and writes the saved value back therefore + puts both accessors exactly where they started. + """ + self._earlier_psd_mockups = value # type: ignore[assignment] + + @mockups.deleter + def mockups(self) -> None: + """``del client.mockups`` drops the override and restores the default. + + The accessor goes back to the one the client was built with, still + pinned to the endpoint this name has always called. + ``mock.patch.object`` deletes the attribute when its block ends, so + this is the way back from a patched double. + """ + self._earlier_psd_mockups = _PsdMockupsResource( + self._transport, base=EARLIER_PSD_MOCKUPS_PATH + ) def close(self) -> None: """Close the underlying HTTP connection pool.""" diff --git a/src/sudomock/models.py b/src/sudomock/models.py index 4ce4909..22495f6 100644 --- a/src/sudomock/models.py +++ b/src/sudomock/models.py @@ -664,8 +664,10 @@ class VideoOptions(_Outcome): # ``"current"`` delivers ``photo_mockup.*`` / ``photo_mockup_render.*`` and # ``"legacy"`` delivers ``2d_mockup.*`` / ``2d_render.*``; the payload's # ``kind`` follows the same pin. Every other event is spelled the same under -# both. The API pins a new endpoint to ``"current"`` unless told otherwise; -# an endpoint that predates the current names stays on ``"legacy"`` until it +# both. An endpoint that does not name a pin gets the spelling its +# ``event_types`` are written in — the earlier names pin it to ``"legacy"``, +# the current names to ``"current"``, an empty or mixed list to ``"legacy"``. +# An endpoint that predates the current names stays on ``"legacy"`` until it # is re-pinned. WebhookEventNaming = Literal["legacy", "current"] diff --git a/tests/test_earlier_accessor_symmetry.py b/tests/test_earlier_accessor_symmetry.py new file mode 100644 index 0000000..a1ce924 --- /dev/null +++ b/tests/test_earlier_accessor_symmetry.py @@ -0,0 +1,271 @@ +"""The earlier accessors read and write the same field, and can be deleted. + +``client.ai`` and ``client.mockups`` are the accessors that predate the 0.11.0 +naming pass; they stay pinned to the endpoints their callers have always used. +Reading one must return what was last written to it, and writing one must not +disturb ``photo_mockups`` / ``psd_mockups``. Otherwise the ordinary +save-and-restore pattern around a test double (``saved = client.ai`` ... +``client.ai = saved``, or ``mock.patch.object``) silently moves the current +accessor onto the earlier endpoint and leaves it there. + +``mock.patch.object`` also deletes the attribute on exit, so the earlier names +need a deleter; deleting one restores the accessor the client was built with. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest import mock + +import httpx +import pytest + +from sudomock import AsyncSudoMock, SudoMock + +from .conftest import ( + MOCK_2D_MOCKUP_LIST_RESPONSE, + MOCK_MOCKUP_LIST_RESPONSE, + TEST_API_KEY, + TEST_BASE_URL, +) + +if TYPE_CHECKING: + import respx + +EARLIER_PHOTO_PATH = "/api/v1/sudoai/2d-mockups" +EARLIER_PSD_PATH = "/api/v1/mockups" +CURRENT_PHOTO_PATH = "/api/v1/photo-mockups" +CURRENT_PSD_PATH = "/api/v1/psd-mockups" + + +class _Stub: + """Stands in for a resource; records nothing but says it was called.""" + + def list(self) -> str: + return "stubbed" + + +# --------------------------------------------------------------------------- +# Writing an earlier name does not move the current accessor +# --------------------------------------------------------------------------- + + +class TestAssignmentStaysOnTheEarlierName: + def test_ai_assignment_leaves_photo_mockups_alone(self) -> None: + client = SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) + current = client.photo_mockups + client.ai = _Stub() + + assert client.photo_mockups is current + + def test_mockups_assignment_leaves_psd_mockups_alone(self) -> None: + client = SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) + current = client.psd_mockups + client.mockups = _Stub() + + assert client.psd_mockups is current + + async def test_async_ai_assignment_leaves_photo_mockups_alone(self) -> None: + client = AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) + current = client.photo_mockups + client.ai = _Stub() + + assert client.photo_mockups is current + + async def test_async_mockups_assignment_leaves_psd_mockups_alone(self) -> None: + client = AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) + current = client.psd_mockups + client.mockups = _Stub() + + assert client.psd_mockups is current + + +# --------------------------------------------------------------------------- +# Save, override, restore: both accessors come back to where they started +# --------------------------------------------------------------------------- + + +class TestSaveAndRestoreRoundTrip: + def test_ai_round_trip_restores_both_accessors(self) -> None: + client = SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) + with pytest.warns(DeprecationWarning): + saved_earlier = client.ai + saved_current = client.photo_mockups + + client.ai = _Stub() + client.ai = saved_earlier + + with pytest.warns(DeprecationWarning): + assert client.ai is saved_earlier + assert client.photo_mockups is saved_current + + def test_mockups_round_trip_restores_both_accessors(self) -> None: + client = SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) + with pytest.warns(DeprecationWarning): + saved_earlier = client.mockups + saved_current = client.psd_mockups + + client.mockups = _Stub() + client.mockups = saved_earlier + + with pytest.warns(DeprecationWarning): + assert client.mockups is saved_earlier + assert client.psd_mockups is saved_current + + async def test_async_round_trip_restores_both_accessors(self) -> None: + client = AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) + with pytest.warns(DeprecationWarning): + saved_ai = client.ai + saved_mockups = client.mockups + saved_photo = client.photo_mockups + saved_psd = client.psd_mockups + + client.ai = _Stub() + client.mockups = _Stub() + client.ai = saved_ai + client.mockups = saved_mockups + + with pytest.warns(DeprecationWarning): + assert client.ai is saved_ai + assert client.mockups is saved_mockups + assert client.photo_mockups is saved_photo + assert client.psd_mockups is saved_psd + + def test_round_trip_keeps_the_current_accessor_on_the_current_endpoint( + self, mock_api: respx.MockRouter + ) -> None: + earlier = mock_api.get(EARLIER_PHOTO_PATH).mock( + return_value=httpx.Response(200, json=MOCK_2D_MOCKUP_LIST_RESPONSE) + ) + current = mock_api.get(CURRENT_PHOTO_PATH).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): + saved = client.ai + client.ai = _Stub() + client.ai = saved + + client.photo_mockups.list() + + assert current.called + assert not earlier.called + + +# --------------------------------------------------------------------------- +# Deleting an earlier name (what mock.patch.object does on exit) +# --------------------------------------------------------------------------- + + +class TestEarlierNamesAreDeletable: + def test_patch_object_ai_round_trip(self, mock_api: respx.MockRouter) -> None: + earlier = mock_api.get(EARLIER_PHOTO_PATH).mock( + return_value=httpx.Response(200, json=MOCK_2D_MOCKUP_LIST_RESPONSE) + ) + current = mock_api.get(CURRENT_PHOTO_PATH).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: + saved_current = client.photo_mockups + stub = _Stub() + with mock.patch.object(client, "ai", stub): + with pytest.warns(DeprecationWarning): + assert client.ai is stub + + # Back to the accessor the client was built with: earlier endpoint. + with pytest.warns(DeprecationWarning): + client.ai.list() + assert client.photo_mockups is saved_current + + assert earlier.called + assert not current.called + + def test_patch_object_mockups_round_trip(self, mock_api: respx.MockRouter) -> None: + earlier = mock_api.get(EARLIER_PSD_PATH).mock( + return_value=httpx.Response(200, json=MOCK_MOCKUP_LIST_RESPONSE) + ) + current = mock_api.get(CURRENT_PSD_PATH).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: + saved_current = client.psd_mockups + with mock.patch.object(client, "mockups", _Stub()): + pass + + with pytest.warns(DeprecationWarning): + client.mockups.list() + assert client.psd_mockups is saved_current + + assert earlier.called + assert not current.called + + async def test_async_patch_object_round_trip(self, mock_api: respx.MockRouter) -> None: + earlier = mock_api.get(EARLIER_PHOTO_PATH).mock( + return_value=httpx.Response(200, json=MOCK_2D_MOCKUP_LIST_RESPONSE) + ) + current = mock_api.get(CURRENT_PHOTO_PATH).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: + saved_current = client.photo_mockups + with mock.patch.object(client, "ai", _Stub()): + pass + + with pytest.warns(DeprecationWarning): + await client.ai.list() + assert client.photo_mockups is saved_current + + assert earlier.called + assert not current.called + + async def test_async_patch_object_mockups_round_trip( + self, mock_api: respx.MockRouter + ) -> None: + earlier = mock_api.get(EARLIER_PSD_PATH).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: + with mock.patch.object(client, "mockups", _Stub()): + pass + with pytest.warns(DeprecationWarning): + await client.mockups.list() + + assert earlier.called + + +# --------------------------------------------------------------------------- +# Watchdog: everything the earlier names already did keeps happening +# --------------------------------------------------------------------------- + + +class TestEarlierNamesKeepTheirExistingBehaviour: + def test_reading_an_earlier_name_still_warns(self) -> None: + client = SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) + with pytest.warns(DeprecationWarning): + assert client.ai is not None + with pytest.warns(DeprecationWarning): + assert client.mockups is not None + + def test_assignment_is_still_what_the_earlier_name_returns(self) -> None: + client = SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) + stub = _Stub() + client.ai = stub + with pytest.warns(DeprecationWarning): + assert client.ai is stub + assert client.ai.list() == "stubbed" + + def test_earlier_names_still_call_earlier_endpoints( + self, mock_api: respx.MockRouter + ) -> None: + earlier = mock_api.get(EARLIER_PHOTO_PATH).mock( + return_value=httpx.Response(200, json=MOCK_2D_MOCKUP_LIST_RESPONSE) + ) + current = mock_api.get(CURRENT_PHOTO_PATH).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 earlier.called + assert not current.called diff --git a/tests/test_family_paths.py b/tests/test_family_paths.py index 8a0b683..bcd73ba 100644 --- a/tests/test_family_paths.py +++ b/tests/test_family_paths.py @@ -2,8 +2,9 @@ ``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 +``client.mockups`` keep working as deprecated accessors, pinned to the +endpoints they have always called (see ``test_legacy_accessor_paths.py``), and +the ``PhotoMockup*`` model names are the same classes as the older ``TwoDMockup*`` / ``AIRender`` names. """ @@ -113,28 +114,43 @@ async def test_async_psd_mockups_list_uses_family_path( class TestDeprecatedAliases: - def test_sync_ai_is_photo_mockups(self) -> None: + """The earlier names still work, still warn, and still call what they called. + + They are their own objects rather than the current accessors, because the + endpoint they point at is the one their callers were already using. The + wire paths themselves are asserted in ``test_legacy_accessor_paths.py``. + """ + + def test_sync_ai_serves_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 + accessor = client.ai + assert isinstance(accessor, type(client.photo_mockups)) + assert accessor is not client.photo_mockups - def test_sync_mockups_is_psd_mockups(self) -> None: + def test_sync_mockups_serves_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 + accessor = client.mockups + assert isinstance(accessor, type(client.psd_mockups)) + assert accessor is not client.psd_mockups - async def test_async_ai_is_photo_mockups(self) -> None: + async def test_async_ai_serves_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 + accessor = client.ai + assert isinstance(accessor, type(client.photo_mockups)) + assert accessor is not client.photo_mockups - async def test_async_mockups_is_psd_mockups(self) -> None: + async def test_async_mockups_serves_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 + accessor = client.mockups + assert isinstance(accessor, type(client.psd_mockups)) + assert accessor is not 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( + def test_alias_reaches_the_endpoint_it_always_reached(self, mock_api: respx.MockRouter) -> None: + route = mock_api.get("/api/v1/sudoai/2d-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: diff --git a/tests/test_legacy_accessor_paths.py b/tests/test_legacy_accessor_paths.py new file mode 100644 index 0000000..b5514f9 --- /dev/null +++ b/tests/test_legacy_accessor_paths.py @@ -0,0 +1,241 @@ +"""Earlier accessors stay pinned to the endpoints they have always called. + +``client.ai`` and ``client.mockups`` are the accessors that shipped before the +0.11.0 naming pass. Code written against them is code that was never changed, +so they keep calling ``/api/v1/sudoai/2d-mockups`` and ``/api/v1/mockups`` — +the endpoints those callers have been reaching all along. ``photo_mockups`` and +``psd_mockups`` are the current accessors and use the current endpoints. + +Assignment to the earlier names keeps working, so test doubles injected by +existing suites are still the object that gets called. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import httpx +import pytest + +from sudomock import AsyncSudoMock, SudoMock + +from .conftest import ( + MOCK_2D_MOCKUP_CREATE_RESPONSE, + MOCK_2D_MOCKUP_GET_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" +PHOTO_ID = "2d-mockup-001" + +EARLIER_PHOTO_PATH = "/api/v1/sudoai/2d-mockups" +EARLIER_PSD_PATH = "/api/v1/mockups" +CURRENT_PHOTO_PATH = "/api/v1/photo-mockups" +CURRENT_PSD_PATH = "/api/v1/psd-mockups" + + +# --------------------------------------------------------------------------- +# Earlier accessors -> earlier endpoints +# --------------------------------------------------------------------------- + + +class TestEarlierAccessorsKeepEarlierEndpoints: + def test_ai_list_calls_earlier_endpoint(self, mock_api: respx.MockRouter) -> None: + earlier = mock_api.get(EARLIER_PHOTO_PATH).mock( + return_value=httpx.Response(200, json=MOCK_2D_MOCKUP_LIST_RESPONSE) + ) + current = mock_api.get(CURRENT_PHOTO_PATH).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 earlier.called + assert not current.called + + def test_ai_create_posts_to_earlier_endpoint(self, mock_api: respx.MockRouter) -> None: + earlier = mock_api.post(EARLIER_PHOTO_PATH).mock( + return_value=httpx.Response(201, json=MOCK_2D_MOCKUP_CREATE_RESPONSE) + ) + current = mock_api.post(CURRENT_PHOTO_PATH).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: + with pytest.warns(DeprecationWarning): + client.ai.create(source_url="https://example.com/p.jpg") + + assert earlier.called + assert not current.called + + def test_ai_get_calls_earlier_endpoint(self, mock_api: respx.MockRouter) -> None: + earlier = mock_api.get(f"{EARLIER_PHOTO_PATH}/{PHOTO_ID}").mock( + return_value=httpx.Response(200, json=MOCK_2D_MOCKUP_GET_RESPONSE) + ) + current = mock_api.get(f"{CURRENT_PHOTO_PATH}/{PHOTO_ID}").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: + with pytest.warns(DeprecationWarning): + client.ai.get(PHOTO_ID) + + assert earlier.called + assert not current.called + + def test_mockups_list_calls_earlier_endpoint(self, mock_api: respx.MockRouter) -> None: + earlier = mock_api.get(EARLIER_PSD_PATH).mock( + return_value=httpx.Response(200, json=MOCK_MOCKUP_LIST_RESPONSE) + ) + current = mock_api.get(CURRENT_PSD_PATH).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 pytest.warns(DeprecationWarning): + client.mockups.list() + + assert earlier.called + assert not current.called + + def test_mockups_get_calls_earlier_endpoint(self, mock_api: respx.MockRouter) -> None: + earlier = mock_api.get(f"{EARLIER_PSD_PATH}/{PSD_UUID}").mock( + return_value=httpx.Response(200, json=MOCK_MOCKUP_GET_RESPONSE) + ) + current = mock_api.get(f"{CURRENT_PSD_PATH}/{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: + with pytest.warns(DeprecationWarning): + client.mockups.get(PSD_UUID) + + assert earlier.called + assert not current.called + + async def test_async_ai_list_calls_earlier_endpoint(self, mock_api: respx.MockRouter) -> None: + earlier = mock_api.get(EARLIER_PHOTO_PATH).mock( + return_value=httpx.Response(200, json=MOCK_2D_MOCKUP_LIST_RESPONSE) + ) + current = mock_api.get(CURRENT_PHOTO_PATH).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: + with pytest.warns(DeprecationWarning): + accessor = client.ai + await accessor.list() + + assert earlier.called + assert not current.called + + async def test_async_mockups_list_calls_earlier_endpoint( + self, mock_api: respx.MockRouter + ) -> None: + earlier = mock_api.get(EARLIER_PSD_PATH).mock( + return_value=httpx.Response(200, json=MOCK_MOCKUP_LIST_RESPONSE) + ) + current = mock_api.get(CURRENT_PSD_PATH).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: + with pytest.warns(DeprecationWarning): + accessor = client.mockups + await accessor.list() + + assert earlier.called + assert not current.called + + +# --------------------------------------------------------------------------- +# Current accessors -> current endpoints +# --------------------------------------------------------------------------- + + +class TestCurrentAccessorsKeepCurrentEndpoints: + def test_photo_mockups_list_calls_current_endpoint(self, mock_api: respx.MockRouter) -> None: + earlier = mock_api.get(EARLIER_PHOTO_PATH).mock( + return_value=httpx.Response(200, json=MOCK_2D_MOCKUP_LIST_RESPONSE) + ) + current = mock_api.get(CURRENT_PHOTO_PATH).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: + client.photo_mockups.list() + + assert current.called + assert not earlier.called + + def test_psd_mockups_list_calls_current_endpoint(self, mock_api: respx.MockRouter) -> None: + earlier = mock_api.get(EARLIER_PSD_PATH).mock( + return_value=httpx.Response(200, json=MOCK_MOCKUP_LIST_RESPONSE) + ) + current = mock_api.get(CURRENT_PSD_PATH).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.psd_mockups.list() + + assert current.called + assert not earlier.called + + async def test_async_photo_mockups_list_calls_current_endpoint( + self, mock_api: respx.MockRouter + ) -> None: + earlier = mock_api.get(EARLIER_PHOTO_PATH).mock( + return_value=httpx.Response(200, json=MOCK_2D_MOCKUP_LIST_RESPONSE) + ) + current = mock_api.get(CURRENT_PHOTO_PATH).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: + await client.photo_mockups.list() + + assert current.called + assert not earlier.called + + +# --------------------------------------------------------------------------- +# Assignment to the earlier names still reaches the injected double +# --------------------------------------------------------------------------- + + +class _Stub: + def __init__(self) -> None: + self.calls: list[str] = [] + + def list(self) -> str: + self.calls.append("list") + return "stubbed" + + +class TestEarlierNamesStayAssignable: + def test_ai_assignment_is_what_ai_returns(self) -> None: + client = SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) + stub = _Stub() + client.ai = stub + with pytest.warns(DeprecationWarning): + assert client.ai is stub + with pytest.warns(DeprecationWarning): + assert client.ai.list() == "stubbed" + assert stub.calls == ["list"] + + def test_mockups_assignment_is_what_mockups_returns(self) -> None: + client = SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) + stub = _Stub() + client.mockups = stub + with pytest.warns(DeprecationWarning): + assert client.mockups is stub + + async def test_async_assignment_is_what_the_earlier_names_return(self) -> None: + client = AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) + stub = _Stub() + client.ai = stub + client.mockups = stub + with pytest.warns(DeprecationWarning): + assert client.ai is stub + with pytest.warns(DeprecationWarning): + assert client.mockups is stub diff --git a/tests/test_legacy_assignment.py b/tests/test_legacy_assignment.py index d1490fb..6b52dc9 100644 --- a/tests/test_legacy_assignment.py +++ b/tests/test_legacy_assignment.py @@ -1,4 +1,12 @@ -"""Earlier accessor names stay assignable so existing test doubles keep working.""" +"""Earlier accessor names stay assignable so existing test doubles keep working. + +Assignment lands on the name it was written to: ``c.ai`` reads back the double +and ``c.photo_mockups`` stays the resource the client was built with. Writing +one name does not move the other, so a suite that saves and restores an +accessor leaves the client where it found it. +""" + +import warnings import sudomock @@ -8,24 +16,38 @@ def __init__(self): self.calls = [] +def _read(client, name): + """Read a deprecated accessor without the warning reaching the report.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + return getattr(client, name) + + def test_legacy_ai_assignment_still_works(): c = sudomock.SudoMock(api_key="sm_test") + current = c.photo_mockups stub = _Stub() c.ai = stub # must not raise - assert c.photo_mockups is stub + assert _read(c, "ai") is stub + assert c.photo_mockups is current def test_legacy_mockups_assignment_still_works(): c = sudomock.SudoMock(api_key="sm_test") + current = c.psd_mockups stub = _Stub() c.mockups = stub - assert c.psd_mockups is stub + assert _read(c, "mockups") is stub + assert c.psd_mockups is current def test_async_legacy_assignment_still_works(): c = sudomock.AsyncSudoMock(api_key="sm_test") + photo, psd = c.photo_mockups, c.psd_mockups stub = _Stub() c.ai = stub - assert c.photo_mockups is stub + assert _read(c, "ai") is stub + assert c.photo_mockups is photo c.mockups = stub - assert c.psd_mockups is stub + assert _read(c, "mockups") is stub + assert c.psd_mockups is psd