From 6828a8528708d5aee0e60fae765db28bfcd68869 Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Tue, 9 Sep 2025 15:32:11 +0100 Subject: [PATCH] MPT-13316 Add catalog products - Added Catalog - Added ProductsService --- mpt_api_client/mpt_client.py | 16 ++- mpt_api_client/resources/__init__.py | 3 +- mpt_api_client/resources/catalog/__init__.py | 3 + mpt_api_client/resources/catalog/catalog.py | 26 ++++ mpt_api_client/resources/catalog/mixins.py | 75 ++++++++++ mpt_api_client/resources/catalog/products.py | 47 +++++++ pyproject.toml | 2 +- setup.cfg | 2 + tests/resources/catalog/test_catalog.py | 58 ++++++++ tests/resources/catalog/test_mixins.py | 139 +++++++++++++++++++ tests/resources/catalog/test_products.py | 27 ++++ tests/test_mpt.py | 12 +- 12 files changed, 402 insertions(+), 8 deletions(-) create mode 100644 mpt_api_client/resources/catalog/__init__.py create mode 100644 mpt_api_client/resources/catalog/catalog.py create mode 100644 mpt_api_client/resources/catalog/mixins.py create mode 100644 mpt_api_client/resources/catalog/products.py create mode 100644 tests/resources/catalog/test_catalog.py create mode 100644 tests/resources/catalog/test_mixins.py create mode 100644 tests/resources/catalog/test_products.py diff --git a/mpt_api_client/mpt_client.py b/mpt_api_client/mpt_client.py index 0a01760a..633e3168 100644 --- a/mpt_api_client/mpt_client.py +++ b/mpt_api_client/mpt_client.py @@ -1,7 +1,7 @@ from typing import Self from mpt_api_client.http import AsyncHTTPClient, HTTPClient -from mpt_api_client.resources import AsyncCommerce, Commerce +from mpt_api_client.resources import AsyncCatalog, AsyncCommerce, Catalog, Commerce class AsyncMPTClient: @@ -28,7 +28,12 @@ def from_config(cls, api_token: str, base_url: str) -> Self: return cls(AsyncHTTPClient(base_url=base_url, api_token=api_token)) @property - def commerce(self) -> "AsyncCommerce": + def catalog(self) -> AsyncCatalog: + """Catalog MPT API Client.""" + return AsyncCatalog(http_client=self.http_client) + + @property + def commerce(self) -> AsyncCommerce: """Commerce MPT API Client.""" return AsyncCommerce(http_client=self.http_client) @@ -57,7 +62,7 @@ def from_config(cls, api_token: str, base_url: str) -> Self: return cls(HTTPClient(base_url=base_url, api_token=api_token)) @property - def commerce(self) -> "Commerce": + def commerce(self) -> Commerce: """Commerce MPT API Client. The Commerce API provides a comprehensive set of endpoints @@ -65,3 +70,8 @@ def commerce(self) -> "Commerce": within a vendor-client-ops ecosystem. """ return Commerce(http_client=self.http_client) + + @property + def catalog(self) -> Catalog: + """Catalog MPT API Client.""" + return Catalog(http_client=self.http_client) diff --git a/mpt_api_client/resources/__init__.py b/mpt_api_client/resources/__init__.py index 9d0d49e6..b75c3cf6 100644 --- a/mpt_api_client/resources/__init__.py +++ b/mpt_api_client/resources/__init__.py @@ -1,3 +1,4 @@ +from mpt_api_client.resources.catalog import AsyncCatalog, Catalog from mpt_api_client.resources.commerce import AsyncCommerce, Commerce -__all__ = ["AsyncCommerce", "Commerce"] # noqa: WPS410 +__all__ = ["AsyncCatalog", "AsyncCommerce", "Catalog", "Commerce"] # noqa: WPS410 diff --git a/mpt_api_client/resources/catalog/__init__.py b/mpt_api_client/resources/catalog/__init__.py new file mode 100644 index 00000000..fe60b03d --- /dev/null +++ b/mpt_api_client/resources/catalog/__init__.py @@ -0,0 +1,3 @@ +from mpt_api_client.resources.catalog.catalog import AsyncCatalog, Catalog + +__all__ = ["AsyncCatalog", "Catalog"] # noqa: WPS410 diff --git a/mpt_api_client/resources/catalog/catalog.py b/mpt_api_client/resources/catalog/catalog.py new file mode 100644 index 00000000..4c76c1d3 --- /dev/null +++ b/mpt_api_client/resources/catalog/catalog.py @@ -0,0 +1,26 @@ +from mpt_api_client.http import AsyncHTTPClient, HTTPClient +from mpt_api_client.resources.catalog.products import AsyncProductsService, ProductsService + + +class Catalog: + """Catalog MPT API Module.""" + + def __init__(self, *, http_client: HTTPClient): + self.http_client = http_client + + @property + def products(self) -> ProductsService: + """Products service.""" + return ProductsService(http_client=self.http_client) + + +class AsyncCatalog: + """Catalog MPT API Module.""" + + def __init__(self, *, http_client: AsyncHTTPClient): + self.http_client = http_client + + @property + def products(self) -> AsyncProductsService: + """Products service.""" + return AsyncProductsService(http_client=self.http_client) diff --git a/mpt_api_client/resources/catalog/mixins.py b/mpt_api_client/resources/catalog/mixins.py new file mode 100644 index 00000000..05ce4aed --- /dev/null +++ b/mpt_api_client/resources/catalog/mixins.py @@ -0,0 +1,75 @@ +from mpt_api_client.models import ResourceData + + +class PublishableMixin[Model]: + """Publishable mixin adds the ability to review, publish and unpublish.""" + + def review(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Update state to Pending. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "review", json=resource_data + ) + + def publish(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Update state to Published. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "publish", json=resource_data + ) + + def unpublish(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Update state to Unpublished. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "unpublish", json=resource_data + ) + + +class AsyncPublishableMixin[Model]: + """Publishable mixin adds the ability to review, publish and unpublish.""" + + async def review(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Update state to reviewing. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return await self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "review", json=resource_data + ) + + async def publish(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Update state to Published. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return await self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "publish", json=resource_data + ) + + async def unpublish(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Update state to Unpublished. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return await self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "unpublish", json=resource_data + ) diff --git a/mpt_api_client/resources/catalog/products.py b/mpt_api_client/resources/catalog/products.py new file mode 100644 index 00000000..3b451c66 --- /dev/null +++ b/mpt_api_client/resources/catalog/products.py @@ -0,0 +1,47 @@ +from mpt_api_client.http import AsyncService, CreateMixin, Service +from mpt_api_client.http.mixins import ( + AsyncCreateMixin, + AsyncDeleteMixin, + AsyncUpdateMixin, + DeleteMixin, + UpdateMixin, +) +from mpt_api_client.models import Model +from mpt_api_client.resources.catalog.mixins import ( + AsyncPublishableMixin, + PublishableMixin, +) + + +class Product(Model): + """Product resource.""" + + +class ProductsServiceConfig: + """Products service configuration.""" + + _endpoint = "/public/v1/catalog/products" + _model_class = Product + _collection_key = "data" + + +class ProductsService( + CreateMixin[Product], + DeleteMixin, + UpdateMixin[Product], + PublishableMixin[Product], + Service[Product], + ProductsServiceConfig, +): + """Products service.""" + + +class AsyncProductsService( + AsyncCreateMixin[Product], + AsyncDeleteMixin, + AsyncUpdateMixin[Product], + AsyncPublishableMixin[Product], + AsyncService[Product], + ProductsServiceConfig, +): + """Products service.""" diff --git a/pyproject.toml b/pyproject.toml index ffa4fadb..95705ea4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ build-backend = "hatchling.build" [tool.pytest.ini_options] testpaths = "tests" pythonpath = "." -addopts = "--cov=mpt_api_client --cov-report=term-missing --cov-report=html --cov-report=xml" +addopts = "--cov=mpt_api_client --cov-report=term-missing --cov-report=html --cov-report=xml --import-mode=importlib" log_cli = false asyncio_mode = "auto" filterwarnings = [ diff --git a/setup.cfg b/setup.cfg index f4133b87..983097f2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -33,8 +33,10 @@ extend-ignore = per-file-ignores = mpt_api_client/rql/query_builder.py: WPS110 WPS115 WPS210 WPS214 + mpt_api_client/resources/catalog/products.py: WPS215 tests/http/test_async_service.py: WPS204 WPS202 tests/http/test_service.py: WPS204 WPS202 + tests/*: # Allow magic strings. WPS432 diff --git a/tests/resources/catalog/test_catalog.py b/tests/resources/catalog/test_catalog.py new file mode 100644 index 00000000..54255269 --- /dev/null +++ b/tests/resources/catalog/test_catalog.py @@ -0,0 +1,58 @@ +import pytest + +from mpt_api_client.http import AsyncHTTPClient +from mpt_api_client.resources.catalog import AsyncCatalog, Catalog +from mpt_api_client.resources.catalog.products import AsyncProductsService, ProductsService + + +def test_catalog_init(http_client): + catalog = Catalog(http_client=http_client) + + assert isinstance(catalog, Catalog) + assert catalog.http_client is http_client + + +def test_catalog_products_multiple_calls(http_client): + catalog = Catalog(http_client=http_client) + + products_service = catalog.products + products_service_additional = catalog.products + + assert products_service is not products_service_additional + assert isinstance(products_service, ProductsService) + assert isinstance(products_service_additional, ProductsService) + + +def test_async_catalog_init(async_http_client: AsyncHTTPClient): + catalog = AsyncCatalog(http_client=async_http_client) + + assert isinstance(catalog, AsyncCatalog) + assert catalog.http_client is async_http_client + + +@pytest.mark.parametrize( + ("attr_name", "expected"), + [ + ("products", ProductsService), + ], +) +def test_catalog_properties(http_client, attr_name, expected): + catalog = Catalog(http_client=http_client) + + service = getattr(catalog, attr_name) + + assert isinstance(service, expected) + + +@pytest.mark.parametrize( + ("attr_name", "expected"), + [ + ("products", AsyncProductsService), + ], +) +def test_async_catalog_properties(http_client, attr_name, expected): + catalog = AsyncCatalog(http_client=http_client) + + service = getattr(catalog, attr_name) + + assert isinstance(service, expected) diff --git a/tests/resources/catalog/test_mixins.py b/tests/resources/catalog/test_mixins.py new file mode 100644 index 00000000..ee982757 --- /dev/null +++ b/tests/resources/catalog/test_mixins.py @@ -0,0 +1,139 @@ +import httpx +import pytest +import respx + +from mpt_api_client.resources.catalog.products import AsyncProductsService, Product, ProductsService + + +@pytest.fixture +def product_service(http_client): + return ProductsService(http_client=http_client) + + +@pytest.fixture +def async_product_service(async_http_client): + return AsyncProductsService(http_client=async_http_client) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("review", {"id": "PRD-123", "status": "update"}), + ("publish", {"id": "PRD-123", "status": "update"}), + ("unpublish", {"id": "PRD-123", "status": "update"}), + ], +) +def test_custom_resource_actions(product_service, action, input_status): + request_expected_content = b'{"id":"PRD-123","status":"update"}' + response_expected_data = {"id": "PRD-123", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/catalog/products/PRD-123/{action}" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + order = getattr(product_service, action)("PRD-123", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + + assert request.content == request_expected_content + assert order.to_dict() == response_expected_data + assert isinstance(order, Product) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("review", None), + ("publish", None), + ("unpublish", None), + ], +) +def test_custom_resource_actions_no_data(product_service, action, input_status): + request_expected_content = b"" + response_expected_data = {"id": "PRD-123", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/catalog/products/PRD-123/{action}" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + + order = getattr(product_service, action)("PRD-123") + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.content == request_expected_content + assert order.to_dict() == response_expected_data + assert isinstance(order, Product) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("review", {"id": "PRD-123", "status": "update"}), + ("publish", {"id": "PRD-123", "status": "update"}), + ("unpublish", {"id": "PRD-123", "status": "update"}), + ], +) +async def test_async_custom_resource_actions(async_product_service, action, input_status): + request_expected_content = b'{"id":"PRD-123","status":"update"}' + response_expected_data = {"id": "PRD-123", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/catalog/products/PRD-123/{action}" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + order = await getattr(async_product_service, action)("PRD-123", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + + assert request.content == request_expected_content + assert order.to_dict() == response_expected_data + assert isinstance(order, Product) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("review", None), + ("publish", None), + ("unpublish", None), + ], +) +async def test_async_custom_resource_actions_no_data(async_product_service, action, input_status): + request_expected_content = b"" + response_expected_data = {"id": "PRD-123", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/catalog/products/PRD-123/{action}" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + + order = await getattr(async_product_service, action)("PRD-123") + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.content == request_expected_content + assert order.to_dict() == response_expected_data + assert isinstance(order, Product) diff --git a/tests/resources/catalog/test_products.py b/tests/resources/catalog/test_products.py new file mode 100644 index 00000000..870b0fa4 --- /dev/null +++ b/tests/resources/catalog/test_products.py @@ -0,0 +1,27 @@ +import pytest + +from mpt_api_client.resources.catalog.products import AsyncProductsService, ProductsService + + +@pytest.fixture +def products_service(http_client): + return ProductsService(http_client=http_client) + + +@pytest.fixture +def async_products_service(async_http_client): + return AsyncProductsService(http_client=async_http_client) + + +@pytest.mark.parametrize( + "method", ["get", "create", "update", "delete", "review", "publish", "unpublish"] +) +def test_mixins_present(products_service, method): + assert hasattr(products_service, method) + + +@pytest.mark.parametrize( + "method", ["get", "create", "update", "delete", "review", "publish", "unpublish"] +) +def test_async_mixins_present(async_products_service, method): + assert hasattr(async_products_service, method) diff --git a/tests/test_mpt.py b/tests/test_mpt.py index bc6ee6a7..096766c3 100644 --- a/tests/test_mpt.py +++ b/tests/test_mpt.py @@ -1,18 +1,22 @@ +import pytest + from mpt_api_client.http import AsyncHTTPClient, HTTPClient from mpt_api_client.mpt_client import AsyncMPTClient, MPTClient -from mpt_api_client.resources import AsyncCommerce, Commerce +from mpt_api_client.resources import AsyncCatalog, AsyncCommerce, Catalog, Commerce from tests.conftest import API_TOKEN, API_URL def test_mpt_client() -> None: mpt = MPTClient.from_config(base_url=API_URL, api_token=API_TOKEN) commerce = mpt.commerce + catalog = mpt.catalog assert isinstance(mpt, MPTClient) assert isinstance(commerce, Commerce) + assert isinstance(catalog, Catalog) -def test_mpt_client_env(monkeypatch): +def test_mpt_client_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("MPT_URL", API_URL) monkeypatch.setenv("MPT_TOKEN", API_TOKEN) @@ -25,12 +29,14 @@ def test_mpt_client_env(monkeypatch): def test_async_mpt_client() -> None: mpt = AsyncMPTClient.from_config(base_url=API_URL, api_token=API_TOKEN) commerce = mpt.commerce + catalog = mpt.catalog assert isinstance(mpt, AsyncMPTClient) assert isinstance(commerce, AsyncCommerce) + assert isinstance(catalog, AsyncCatalog) -def test_async_mpt_client_env(monkeypatch): +def test_async_mpt_client_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("MPT_URL", API_URL) monkeypatch.setenv("MPT_TOKEN", API_TOKEN)