From 4029c67cb6cf3d94785612fed0cb1675beede00d Mon Sep 17 00:00:00 2001 From: SudoMock Labs Date: Sat, 19 Sep 2026 15:00:18 +0300 Subject: [PATCH 1/3] =?UTF-8?q?Eski=20eri=C5=9Fimciler=20eski=20u=C3=A7ta?= =?UTF-8?q?=20kal=C4=B1yor:=20ai=20ve=20mockups=20yay=C4=B1mlanan=20yollar?= =?UTF-8?q?=C4=B1na=20sabitlendi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.11.0'da client.ai ve client.mockups, client.photo_mockups ve client.psd_mockups'ın takma adı yapılmıştı; böylece yayımlanan /api/v1/sudoai/2d-mockups ve /api/v1/mockups yerine yeni uçları çağırır oldular. Kodunu değiştirmeden paketi yükselten çağıran, işlerini yeni uçta açmaya ve 2d_create / 2d_render yerine photo_mockup_create / photo_mockup_render kind'ı almaya başlıyordu: hata yok, ama kind'a göre dallanan kod eşleşmiyor ve iş sessizce duruyordu. - client.ai artık /api/v1/sudoai/2d-mockups, client.mockups /api/v1/mockups uçlarını çağırıyor; ikisi de API tarafından servis edilmeye devam ediyor - client.photo_mockups ve client.psd_mockups değişmedi, yeni uçlarda kalıyor - eski isme atama her iki erişimciyi birden değiştiriyor, böylece enjekte edilen test sahtesi öbür isim üzerinden gerçek API'ye kaçamıyor - uç yolları _http.py'de tek kaynakta toplandı, kaynak sınıflar base parametresi alıyor - tests/test_legacy_accessor_paths.py: respx ile hangi erişimcinin hangi ucu çağırdığının kanıtı; test_family_paths.py'deki takma-ad beklentileri yeni sözleşmeye göre güncellendi - 0.11.1 + CHANGELOG notu --- CHANGELOG.md | 23 +++ pyproject.toml | 2 +- src/sudomock/_http.py | 10 ++ src/sudomock/async_client.py | 73 ++++++--- src/sudomock/client.py | 71 +++++--- tests/test_family_paths.py | 40 +++-- tests/test_legacy_accessor_paths.py | 241 ++++++++++++++++++++++++++++ 7 files changed, 409 insertions(+), 51 deletions(-) create mode 100644 tests/test_legacy_accessor_paths.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a82c76d..8b74af7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ 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` replaces both the earlier and + the current accessor, so a test double injected under either name is the + object that gets called on both. + + ## [0.11.0] - 2026-09-19 ### Added 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..c579cf2 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: @@ -601,8 +604,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 +662,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 +726,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 +797,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 +818,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 +839,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 +848,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,34 +1081,63 @@ 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.""" + """Assignment still works so existing test doubles keep running. + + The double replaces both accessors, so nothing a caller meant to stub + can slip through to the real API on the other name. + """ + self._earlier_photo_mockups = value # type: ignore[assignment] self.photo_mockups = value # type: ignore[assignment] @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.""" + """Assignment still works so existing test doubles keep running. + + The double replaces both accessors, so nothing a caller meant to stub + can slip through to the real API on the other name. + """ + self._earlier_psd_mockups = value # type: ignore[assignment] self.psd_mockups = value # type: ignore[assignment] async def close(self) -> None: diff --git a/src/sudomock/client.py b/src/sudomock/client.py index 23f7c39..109914e 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: @@ -640,8 +645,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 +703,7 @@ def create( resp = self._transport.request( "POST", - "/api/v1/photo-mockups", + self._base, json=body, headers={"Idempotency-Key": idempotency_key}, ) @@ -767,7 +773,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 +844,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 +875,7 @@ def list( """ resp = self._transport.request( "GET", - "/api/v1/photo-mockups", + self._base, params={ "limit": limit, "offset": offset, @@ -890,7 +896,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 +905,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,34 +1144,63 @@ 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.""" + """Assignment still works so existing test doubles keep running. + + The double replaces both accessors, so nothing a caller meant to stub + can slip through to the real API on the other name. + """ + self._earlier_photo_mockups = value # type: ignore[assignment] self.photo_mockups = value # type: ignore[assignment] @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.""" + """Assignment still works so existing test doubles keep running. + + The double replaces both accessors, so nothing a caller meant to stub + can slip through to the real API on the other name. + """ + self._earlier_psd_mockups = value # type: ignore[assignment] self.psd_mockups = value # type: ignore[assignment] def close(self) -> None: 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 From 27280913df3dca10b4d0e89f0d2f51e2073dd227 Mon Sep 17 00:00:00 2001 From: SudoMock Labs Date: Sat, 19 Sep 2026 15:26:32 +0300 Subject: [PATCH 2/3] =?UTF-8?q?Eski=20eri=C5=9Fimci=20okundu=C4=9Fu=20alan?= =?UTF-8?q?a=20yaz=C4=B1l=C4=B1r,=20silinebilir=20de?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eski adların setter'ı hem eski hem yeni erişimciye yazıyordu, getter ise yalnız eskiyi okuyordu. Test sahtesi etrafındaki olağan kaydet-geri yükle deseni (saved = client.ai ... client.ai = saved, ya da mock.patch.object) bu yüzden yeni erişimciyi kalıcı olarak eski uca taşıyordu: sonraki her client.photo_mockups çağrısı /api/v1/sudoai/2d-mockups'a gidiyor ve eski iş yazımlarını geri getiriyordu, hiçbir şey yükselmeden. Artık her eski ad yalnız kendi alanını yazar ve okur; photo_mockups / psd_mockups yerinde kalır. Geri yükleme her iki erişimciyi de başladığı yere koyar. Eski adların deleter'ı da yoktu; mock.patch.object bloğu biterken AttributeError veriyordu. Eski adı silmek override'ı düşürür ve istemcinin kurulduğu erişimciyi, hep çağırdığı uca sabitli olarak geri getirir. Kayıtlı değer değişmez, çağıranın kullandığı yazım korunur. --- src/sudomock/async_client.py | 48 ++++- src/sudomock/client.py | 48 ++++- tests/test_earlier_accessor_symmetry.py | 271 ++++++++++++++++++++++++ tests/test_legacy_assignment.py | 32 ++- 4 files changed, 376 insertions(+), 23 deletions(-) create mode 100644 tests/test_earlier_accessor_symmetry.py diff --git a/src/sudomock/async_client.py b/src/sudomock/async_client.py index c579cf2..dea2f35 100644 --- a/src/sudomock/async_client.py +++ b/src/sudomock/async_client.py @@ -484,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). @@ -1110,11 +1112,25 @@ def ai(self) -> _AsyncPhotoMockupsResource: def ai(self, value: object) -> None: """Assignment still works so existing test doubles keep running. - The double replaces both accessors, so nothing a caller meant to stub - can slip through to the real API on the other name. + 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] - self.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: @@ -1134,11 +1150,25 @@ def mockups(self) -> _AsyncPsdMockupsResource: def mockups(self, value: object) -> None: """Assignment still works so existing test doubles keep running. - The double replaces both accessors, so nothing a caller meant to stub - can slip through to the real API on the other name. + 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] - self.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 109914e..df9d6f6 100644 --- a/src/sudomock/client.py +++ b/src/sudomock/client.py @@ -512,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 @@ -1173,11 +1175,25 @@ def ai(self) -> _PhotoMockupsResource: def ai(self, value: object) -> None: """Assignment still works so existing test doubles keep running. - The double replaces both accessors, so nothing a caller meant to stub - can slip through to the real API on the other name. + 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] - self.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: @@ -1197,11 +1213,25 @@ def mockups(self) -> _PsdMockupsResource: def mockups(self, value: object) -> None: """Assignment still works so existing test doubles keep running. - The double replaces both accessors, so nothing a caller meant to stub - can slip through to the real API on the other name. + 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] - self.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/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_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 From 64d5f4a6357722602e409eda22a8ea73bb91d481 Mon Sep 17 00:00:00 2001 From: SudoMock Labs Date: Sat, 19 Sep 2026 15:26:40 +0300 Subject: [PATCH 3/3] =?UTF-8?q?Webhook=20varsay=C4=B1lan=20pin'i=20a=C3=A7?= =?UTF-8?q?=C4=B1klamas=C4=B1:=20yaz=C4=B1mdan=20t=C3=BCretilen=20pin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doküman metinleri "pin gönderilmezse API ucu current'a sabitler" diyordu; bu artık olanı anlatmıyor. Pin gönderilmediğinde uç, event_types listesinin yazımından türetilir: eski adlarla yazılmış liste legacy, güncel adlarla yazılmış liste current, boş ya da karışık liste güvenli taraf olan legacy. Pin açıkça gönderilirse ona uyulur; o yol değişmedi. client.py, async_client.py, models.py ve README tablosundaki çelişki kapatıldı. CHANGELOG 0.11.1 kaydına simetri, deleter ve bu doküman düzeltmesi eklendi. --- CHANGELOG.md | 29 ++++++++++++++++++++++++++--- README.md | 2 +- src/sudomock/models.py | 6 ++++-- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b74af7..8339634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,9 +25,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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` replaces both the earlier and - the current accessor, so a test double injected under either name is the - object that gets called on both. +- 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 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/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"]