From 3cde6834fde5735e1c4e980a3f22f67ad274d2e5 Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Fri, 22 Aug 2025 11:41:12 +0100 Subject: [PATCH 1/2] MPT-12358 Refactor for collection client async --- mpt_api_client/http/collection.py | 66 +++++++++++-------- mpt_api_client/http/resource.py | 8 +-- mpt_api_client/mptclient.py | 14 ++-- mpt_api_client/registry.py | 4 +- mpt_api_client/resources/__init__.py | 4 +- mpt_api_client/resources/order.py | 4 +- .../collection/test_collection_client_init.py | 8 +-- ...ion_client.py => test_collection_mixin.py} | 16 +++++ tests/http/conftest.py | 10 +-- .../orders/test_order_collection_client.py | 4 +- tests/test_mpt.py | 6 +- tests/test_registry.py | 22 ++++--- 12 files changed, 99 insertions(+), 67 deletions(-) rename tests/http/collection/{test_collection_client.py => test_collection_mixin.py} (82%) diff --git a/mpt_api_client/http/collection.py b/mpt_api_client/http/collection.py index 38e5be4c..fe8a1396 100644 --- a/mpt_api_client/http/collection.py +++ b/mpt_api_client/http/collection.py @@ -5,52 +5,39 @@ import httpx -from mpt_api_client.http.client import HTTPClient +from mpt_api_client.http.client import HTTPClient, HTTPClientAsync from mpt_api_client.http.resource import ResourceBaseClient from mpt_api_client.models import Collection, Resource from mpt_api_client.rql.query_builder import RQLQuery -class CollectionBaseClient[ResourceModel: Resource, ResourceClient: ResourceBaseClient[Resource]]( # noqa: WPS214 - ABC -): - """Immutable Base client for RESTful resource collections. - - Examples: - active_orders_cc = order_collection.filter(RQLQuery(status="active")) - active_orders = active_orders_cc.order_by("created").iterate() - product_active_orders = active_orders_cc.filter(RQLQuery(product__id="PRD-1")).iterate() - - new_order = order_collection.create(order_data) - - """ +class CollectionMixin: + """Mixin for collection clients.""" _endpoint: str - _resource_class: type[ResourceModel] - _resource_client_class: type[ResourceClient] - _collection_class: type[Collection[ResourceModel]] + _resource_class: type[Any] + _resource_client_class: type[Any] + _collection_class: type[Collection[Any]] def __init__( self, + http_client: HTTPClient | HTTPClientAsync, query_rql: RQLQuery | None = None, - client: HTTPClient | None = None, ) -> None: - self.mpt_client = client or HTTPClient() + self.http_client = http_client self.query_rql: RQLQuery | None = query_rql self.query_order_by: list[str] | None = None self.query_select: list[str] | None = None @classmethod - def clone( - cls, collection_client: "CollectionBaseClient[ResourceModel, ResourceClient]" - ) -> Self: + def clone(cls, collection_client: "CollectionMixin") -> Self: """Create a copy of collection client for immutable operations. Returns: New collection client with same settings. """ new_collection = cls( - client=collection_client.mpt_client, + http_client=collection_client.http_client, query_rql=collection_client.query_rql, ) new_collection.query_order_by = ( @@ -128,6 +115,33 @@ def select(self, *fields: str) -> Self: new_client.query_select = list(fields) return new_client + +class CollectionClientBase[ResourceModel: Resource, ResourceClient: ResourceBaseClient[Resource]]( # noqa: WPS214 + ABC, CollectionMixin +): + """Immutable Base client for RESTful resource collections. + + Examples: + active_orders_cc = order_collection.filter(RQLQuery(status="active")) + active_orders = active_orders_cc.order_by("created").iterate() + product_active_orders = active_orders_cc.filter(RQLQuery(product__id="PRD-1")).iterate() + + new_order = order_collection.create(order_data) + + """ + + _resource_class: type[ResourceModel] + _resource_client_class: type[ResourceClient] + _collection_class: type[Collection[ResourceModel]] + + def __init__( + self, + query_rql: RQLQuery | None = None, + http_client: HTTPClient | None = None, + ) -> None: + self.http_client: HTTPClient = http_client or HTTPClient() # type: ignore[mutable-override] + CollectionMixin.__init__(self, http_client=self.http_client, query_rql=query_rql) + def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[ResourceModel]: """Fetch one page of resources. @@ -185,7 +199,7 @@ def iterate(self, batch_size: int = 100) -> Iterator[ResourceModel]: def get(self, resource_id: str) -> ResourceClient: """Get resource by resource_id.""" - return self._resource_client_class(client=self.mpt_client, resource_id=resource_id) + return self._resource_client_class(http_client=self.http_client, resource_id=resource_id) def create(self, resource_data: dict[str, Any]) -> ResourceModel: """Create a new resource using `POST /endpoint`. @@ -193,7 +207,7 @@ def create(self, resource_data: dict[str, Any]) -> ResourceModel: Returns: New resource created. """ - response = self.mpt_client.post(self._endpoint, json=resource_data) + response = self.http_client.post(self._endpoint, json=resource_data) response.raise_for_status() return self._resource_class.from_response(response) @@ -208,7 +222,7 @@ def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Re HTTPStatusError: if the response status code is not 200. """ pagination_params: dict[str, int] = {"limit": limit, "offset": offset} - response = self.mpt_client.get(self.build_url(pagination_params)) + response = self.http_client.get(self.build_url(pagination_params)) response.raise_for_status() return response diff --git a/mpt_api_client/http/resource.py b/mpt_api_client/http/resource.py index 16677f7b..bc5fb486 100644 --- a/mpt_api_client/http/resource.py +++ b/mpt_api_client/http/resource.py @@ -12,10 +12,10 @@ class ResourceBaseClient[ResourceModel: Resource](ABC): # noqa: WPS214 _endpoint: str _resource_class: type[ResourceModel] - _safe_attributes: ClassVar[set[str]] = {"mpt_client_", "resource_id_", "resource_"} + _safe_attributes: ClassVar[set[str]] = {"http_client_", "resource_id_", "resource_"} - def __init__(self, client: HTTPClient, resource_id: str) -> None: - self.mpt_client_ = client # noqa: WPS120 + def __init__(self, http_client: HTTPClient, resource_id: str) -> None: + self.http_client_ = http_client # noqa: WPS120 self.resource_id_ = resource_id # noqa: WPS120 self.resource_: Resource | None = None # noqa: WPS120 @@ -78,7 +78,7 @@ def do_action( HTTPError: If the action fails. """ url = f"{self.resource_url}/{url}" if url else self.resource_url - response = self.mpt_client_.request(method, url, json=json) + response = self.http_client_.request(method, url, json=json) response.raise_for_status() return response diff --git a/mpt_api_client/mptclient.py b/mpt_api_client/mptclient.py index 45dcae56..e5a1617b 100644 --- a/mpt_api_client/mptclient.py +++ b/mpt_api_client/mptclient.py @@ -1,6 +1,6 @@ from mpt_api_client.http.client import HTTPClient from mpt_api_client.registry import Registry, commerce -from mpt_api_client.resources import OrderCollectionClient +from mpt_api_client.resources import OrderCollectionClientBase class MPTClientBase: @@ -11,13 +11,13 @@ def __init__( base_url: str | None = None, api_key: str | None = None, registry: Registry | None = None, - mpt_client: HTTPClient | None = None, + http_client: HTTPClient | None = None, ): - self.mpt_client = mpt_client or HTTPClient(base_url=base_url, api_token=api_key) + self.http_client = http_client or HTTPClient(base_url=base_url, api_token=api_key) self.registry: Registry = registry or Registry() def __getattr__(self, name): # type: ignore[no-untyped-def] - return self.registry.get(name)(client=self.mpt_client) + return self.registry.get(name)(http_client=self.http_client) class MPTClient(MPTClientBase): @@ -31,14 +31,14 @@ def commerce(self) -> "CommerceMpt": for managing agreements, requests, subscriptions, and orders within a vendor-client-ops ecosystem. """ - return CommerceMpt(mpt_client=self.mpt_client, registry=commerce) + return CommerceMpt(http_client=self.http_client, registry=commerce) class CommerceMpt(MPTClientBase): """Commerce MPT API Client.""" @property - def orders(self) -> OrderCollectionClient: + def orders(self) -> OrderCollectionClientBase: """Orders MPT API collection. The Orders API provides a comprehensive set of endpoints @@ -54,4 +54,4 @@ def orders(self) -> OrderCollectionClient: [...] """ - return self.registry.get("orders")(client=self.mpt_client) # type: ignore[return-value] + return self.registry.get("orders")(http_client=self.http_client) # type: ignore[return-value] diff --git a/mpt_api_client/registry.py b/mpt_api_client/registry.py index cefef24a..f7a3acf5 100644 --- a/mpt_api_client/registry.py +++ b/mpt_api_client/registry.py @@ -1,9 +1,9 @@ from collections.abc import Callable from typing import Any -from mpt_api_client.http.collection import CollectionBaseClient +from mpt_api_client.http.collection import CollectionClientBase -ItemType = type[CollectionBaseClient[Any, Any]] +ItemType = type[CollectionClientBase[Any, Any]] class Registry: diff --git a/mpt_api_client/resources/__init__.py b/mpt_api_client/resources/__init__.py index 89e8ed47..97ace29a 100644 --- a/mpt_api_client/resources/__init__.py +++ b/mpt_api_client/resources/__init__.py @@ -1,3 +1,3 @@ -from mpt_api_client.resources.order import Order, OrderCollectionClient, OrderResourceClient +from mpt_api_client.resources.order import Order, OrderCollectionClientBase, OrderResourceClient -__all__ = ["Order", "OrderCollectionClient", "OrderResourceClient"] # noqa: WPS410 +__all__ = ["Order", "OrderCollectionClientBase", "OrderResourceClient"] # noqa: WPS410 diff --git a/mpt_api_client/resources/order.py b/mpt_api_client/resources/order.py index 5be89616..79ba72f2 100644 --- a/mpt_api_client/resources/order.py +++ b/mpt_api_client/resources/order.py @@ -1,6 +1,6 @@ from typing import Any -from mpt_api_client.http.collection import CollectionBaseClient +from mpt_api_client.http.collection import CollectionClientBase from mpt_api_client.http.resource import ResourceBaseClient from mpt_api_client.models import Collection, Resource from mpt_api_client.registry import commerce @@ -76,7 +76,7 @@ def template(self) -> str: @commerce("orders") -class OrderCollectionClient(CollectionBaseClient[Order, OrderResourceClient]): +class OrderCollectionClientBase(CollectionClientBase[Order, OrderResourceClient]): """Orders client.""" _endpoint = "/public/v1/commerce/orders" diff --git a/tests/http/collection/test_collection_client_init.py b/tests/http/collection/test_collection_client_init.py index 488a2aee..5003a0c4 100644 --- a/tests/http/collection/test_collection_client_init.py +++ b/tests/http/collection/test_collection_client_init.py @@ -2,7 +2,7 @@ from mpt_api_client.http.client import HTTPClient from mpt_api_client.rql.query_builder import RQLQuery -from tests.http.conftest import DummyCollectionClient +from tests.http.conftest import DummyCollectionClientBase @pytest.fixture @@ -16,7 +16,7 @@ def sample_rql_query(): def test_init_defaults(http_client): - collection_client = DummyCollectionClient(client=http_client) + collection_client = DummyCollectionClientBase(http_client=http_client) assert collection_client.query_rql is None assert collection_client.query_order_by is None @@ -25,8 +25,8 @@ def test_init_defaults(http_client): def test_init_with_filter(http_client, sample_rql_query): - collection_client = DummyCollectionClient( - client=http_client, + collection_client = DummyCollectionClientBase( + http_client=http_client, query_rql=sample_rql_query, ) diff --git a/tests/http/collection/test_collection_client.py b/tests/http/collection/test_collection_mixin.py similarity index 82% rename from tests/http/collection/test_collection_client.py rename to tests/http/collection/test_collection_mixin.py index 9001609a..a833fe8f 100644 --- a/tests/http/collection/test_collection_client.py +++ b/tests/http/collection/test_collection_mixin.py @@ -67,3 +67,19 @@ def test_url(collection_client) -> None: "&select=-audit,product.agreements,-product.agreements.product" "&eq(status,active)" ) + + +def test_clone(collection_client) -> None: + configured = ( + collection_client + .filter(RQLQuery(status="active")) + .order_by("created", "-name") + .select("agreement", "-product") + ) + + cloned = configured.clone(configured) + + assert cloned is not configured + assert isinstance(cloned, configured.__class__) + assert cloned.http_client is configured.http_client + assert str(cloned.query_rql) == str(configured.query_rql) diff --git a/tests/http/conftest.py b/tests/http/conftest.py index d39508c2..d6b63e7f 100644 --- a/tests/http/conftest.py +++ b/tests/http/conftest.py @@ -1,7 +1,7 @@ import pytest from mpt_api_client.http.client import HTTPClient, HTTPClientAsync -from mpt_api_client.http.collection import CollectionBaseClient +from mpt_api_client.http.collection import CollectionClientBase from mpt_api_client.http.resource import ResourceBaseClient from mpt_api_client.models import Collection from tests.conftest import DummyResource @@ -12,7 +12,7 @@ class DummyResourceClient(ResourceBaseClient[DummyResource]): _resource_class = DummyResource -class DummyCollectionClient(CollectionBaseClient[DummyResource, DummyResourceClient]): +class DummyCollectionClientBase(CollectionClientBase[DummyResource, DummyResourceClient]): _endpoint = "/api/v1/test" _resource_class = DummyResource _resource_client_class = DummyResourceClient @@ -41,9 +41,9 @@ def http_client_async(api_url, api_token): @pytest.fixture def resource_client(http_client): - return DummyResourceClient(client=http_client, resource_id="RES-123") + return DummyResourceClient(http_client=http_client, resource_id="RES-123") @pytest.fixture -def collection_client(http_client) -> DummyCollectionClient: - return DummyCollectionClient(client=http_client) +def collection_client(http_client) -> DummyCollectionClientBase: + return DummyCollectionClientBase(http_client=http_client) diff --git a/tests/resources/orders/test_order_collection_client.py b/tests/resources/orders/test_order_collection_client.py index 073b0fbc..08a2eaa3 100644 --- a/tests/resources/orders/test_order_collection_client.py +++ b/tests/resources/orders/test_order_collection_client.py @@ -1,6 +1,6 @@ -from mpt_api_client.resources.order import OrderCollectionClient +from mpt_api_client.resources.order import OrderCollectionClientBase def test_order_collection_client(mpt_client): - order_cc = OrderCollectionClient(client=mpt_client) + order_cc = OrderCollectionClientBase(http_client=mpt_client) assert order_cc.query_rql is None diff --git a/tests/test_mpt.py b/tests/test_mpt.py index 3afd59f0..4ecb9c4a 100644 --- a/tests/test_mpt.py +++ b/tests/test_mpt.py @@ -1,7 +1,7 @@ from unittest.mock import Mock from mpt_api_client.mptclient import MPTClient -from mpt_api_client.resources import OrderCollectionClient +from mpt_api_client.resources import OrderCollectionClientBase def test_mapped_module() -> None: @@ -27,5 +27,5 @@ def test_subclient_orders_module(): orders_client = mpt.commerce.orders - assert isinstance(orders_client, OrderCollectionClient) - assert orders_client.mpt_client == mpt.mpt_client + assert isinstance(orders_client, OrderCollectionClientBase) + assert orders_client.http_client == mpt.http_client diff --git a/tests/test_registry.py b/tests/test_registry.py index f2db59ec..3714380f 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -1,6 +1,6 @@ import pytest -from mpt_api_client.http.collection import CollectionBaseClient +from mpt_api_client.http.collection import CollectionClientBase from mpt_api_client.http.resource import ResourceBaseClient from mpt_api_client.models import Resource from mpt_api_client.registry import Registry @@ -10,7 +10,7 @@ class DummyResource(Resource): """Dummy resource for testing.""" -class DummyCollectionClient(CollectionBaseClient): +class DummyCollectionClientBase(CollectionClientBase): _endpoint = "/api/v1/dummy" _resource_class = DummyResource @@ -19,22 +19,22 @@ def test_register_collection_client_successfully(): registry = Registry() keyname = "test_collection" - registry.register(keyname, DummyCollectionClient) + registry.register(keyname, DummyCollectionClientBase) assert keyname in registry.items - assert registry.items[keyname] == DummyCollectionClient - assert registry.get(keyname) == DummyCollectionClient + assert registry.items[keyname] == DummyCollectionClientBase + assert registry.get(keyname) == DummyCollectionClientBase def test_get_registered_client_successfully(): registry = Registry() keyname = "orders" - registry.register(keyname, DummyCollectionClient) + registry.register(keyname, DummyCollectionClientBase) retrieved_client = registry.get(keyname) - assert retrieved_client == DummyCollectionClient + assert retrieved_client == DummyCollectionClientBase def test_get_raise_exception(): @@ -52,7 +52,7 @@ def test_list_keys(): expected_keys = ["orders", "customers", "products"] for keyname in expected_keys: - registry.register(keyname, DummyCollectionClient) + registry.register(keyname, DummyCollectionClientBase) registry_keys = registry.list_keys() @@ -64,10 +64,12 @@ def test_registry_as_decorator(): registry = Registry() @registry("test_call") - class TestCallClient(CollectionBaseClient[DummyResource, ResourceBaseClient[DummyResource]]): # noqa: WPS431 + class TestCallClientBase( # noqa: WPS431 + CollectionClientBase[DummyResource, ResourceBaseClient[DummyResource]] + ): _endpoint = "/api/v1/test-call" _resource_class = DummyResource registered_client = registry.get("test_call") - assert registered_client == TestCallClient + assert registered_client == TestCallClientBase From 3e07b4a05a51cc618fe7368eea4bac3da29a40c9 Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Fri, 22 Aug 2025 12:06:56 +0100 Subject: [PATCH 2/2] MPT-12358 Collection client async --- mpt_api_client/http/collection.py | 116 +++++++++++++- tests/http/collection/conftest.py | 92 +++++++++++ .../test_async_collection_client_create.py | 26 ++++ .../test_async_collection_client_fetch.py | 146 ++++++++++++++++++ .../test_async_collection_client_init.py | 34 ++++ .../test_async_collection_client_iterate.py | 141 +++++++++++++++++ .../test_collection_client_iterate.py | 98 +----------- .../http/collection/test_collection_mixin.py | 3 +- tests/http/conftest.py | 14 +- 9 files changed, 572 insertions(+), 98 deletions(-) create mode 100644 tests/http/collection/conftest.py create mode 100644 tests/http/collection/test_async_collection_client_create.py create mode 100644 tests/http/collection/test_async_collection_client_fetch.py create mode 100644 tests/http/collection/test_async_collection_client_init.py create mode 100644 tests/http/collection/test_async_collection_client_iterate.py diff --git a/mpt_api_client/http/collection.py b/mpt_api_client/http/collection.py index fe8a1396..17a6f124 100644 --- a/mpt_api_client/http/collection.py +++ b/mpt_api_client/http/collection.py @@ -1,6 +1,6 @@ import copy from abc import ABC -from collections.abc import Iterator +from collections.abc import AsyncIterator, Iterator from typing import Any, Self import httpx @@ -226,3 +226,117 @@ def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Re response.raise_for_status() return response + + +class AsyncCollectionClientBase[ + ResourceModel: Resource, + ResourceClient: ResourceBaseClient[Resource], +](ABC, CollectionMixin): + """Immutable Base client for RESTful resource collections. + + Examples: + active_orders_cc = order_collection.filter(RQLQuery(status="active")) + active_orders = active_orders_cc.order_by("created").iterate() + product_active_orders = active_orders_cc.filter(RQLQuery(product__id="PRD-1")).iterate() + + new_order = order_collection.create(order_data) + + """ + + _resource_class: type[ResourceModel] + _resource_client_class: type[ResourceClient] + _collection_class: type[Collection[ResourceModel]] + + def __init__( + self, + query_rql: RQLQuery | None = None, + http_client: HTTPClientAsync | None = None, + ) -> None: + self.http_client: HTTPClientAsync = http_client or HTTPClientAsync() # type: ignore[mutable-override] + CollectionMixin.__init__(self, http_client=self.http_client, query_rql=query_rql) + + async def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[ResourceModel]: + """Fetch one page of resources. + + Returns: + Collection of resources. + """ + response = await self._fetch_page_as_response(limit=limit, offset=offset) + return Collection.from_response(response) + + async def fetch_one(self) -> ResourceModel: + """Fetch one page, expect exactly one result. + + Returns: + One resource. + + Raises: + ValueError: If the total matching records are not exactly one. + """ + response = await self._fetch_page_as_response(limit=1, offset=0) + resource_list: Collection[ResourceModel] = Collection.from_response(response) + total_records = len(resource_list) + if resource_list.meta: + total_records = resource_list.meta.pagination.total + if total_records == 0: + raise ValueError("Expected one result, but got zero results") + if total_records > 1: + raise ValueError(f"Expected one result, but got {total_records} results") + + return resource_list[0] + + async def iterate(self, batch_size: int = 100) -> AsyncIterator[ResourceModel]: + """Iterate over all resources, yielding GenericResource objects. + + Args: + batch_size: Number of resources to fetch per request + + Returns: + Iterator of resources. + """ + offset = 0 + limit = batch_size # Default page size + + while True: + response = await self._fetch_page_as_response(limit=limit, offset=offset) + items_collection: Collection[ResourceModel] = self._collection_class.from_response( + response + ) + for resource in items_collection: + yield resource + + if not items_collection.meta: + break + if not items_collection.meta.pagination.has_next(): + break + offset = items_collection.meta.pagination.next_offset() + + async def get(self, resource_id: str) -> ResourceClient: + """Get resource by resource_id.""" + return self._resource_client_class(http_client=self.http_client, resource_id=resource_id) # type: ignore[arg-type] + + async def create(self, resource_data: dict[str, Any]) -> ResourceModel: + """Create a new resource using `POST /endpoint`. + + Returns: + New resource created. + """ + response = await self.http_client.post(self._endpoint, json=resource_data) + response.raise_for_status() + + return self._resource_class.from_response(response) + + async def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Response: + """Fetch one page of resources. + + Returns: + httpx.Response object. + + Raises: + HTTPStatusError: if the response status code is not 200. + """ + pagination_params: dict[str, int] = {"limit": limit, "offset": offset} + response = await self.http_client.get(self.build_url(pagination_params)) + response.raise_for_status() + + return response diff --git a/tests/http/collection/conftest.py b/tests/http/collection/conftest.py new file mode 100644 index 00000000..fe5325e5 --- /dev/null +++ b/tests/http/collection/conftest.py @@ -0,0 +1,92 @@ +import httpx +import pytest + + +@pytest.fixture +def single_page_response(): + return httpx.Response( + httpx.codes.OK, + json={ + "data": [ + {"id": "ID-1", "name": "Resource 1"}, + {"id": "ID-2", "name": "Resource 2"}, + ], + "$meta": { + "pagination": { + "total": 2, + "offset": 0, + "limit": 100, + } + }, + }, + ) + + +@pytest.fixture +def multi_page_response_page1(): + return httpx.Response( + httpx.codes.OK, + json={ + "data": [ + {"id": "ID-1", "name": "Resource 1"}, + {"id": "ID-2", "name": "Resource 2"}, + ], + "$meta": { + "pagination": { + "total": 4, + "offset": 0, + "limit": 2, + } + }, + }, + ) + + +@pytest.fixture +def multi_page_response_page2(): + return httpx.Response( + httpx.codes.OK, + json={ + "data": [ + {"id": "ID-3", "name": "Resource 3"}, + {"id": "ID-4", "name": "Resource 4"}, + ], + "$meta": { + "pagination": { + "total": 4, + "offset": 2, + "limit": 2, + } + }, + }, + ) + + +@pytest.fixture +def empty_response(): + return httpx.Response( + httpx.codes.OK, + json={ + "data": [], + "$meta": { + "pagination": { + "total": 0, + "offset": 0, + "limit": 100, + } + }, + }, + ) + + +@pytest.fixture +def no_meta_response(): + return httpx.Response( + httpx.codes.OK, + json={ + "data": [ + {"id": "ID-1", "name": "Resource 1"}, + {"id": "ID-2", "name": "Resource 2"}, + ] + }, + ) diff --git a/tests/http/collection/test_async_collection_client_create.py b/tests/http/collection/test_async_collection_client_create.py new file mode 100644 index 00000000..04273499 --- /dev/null +++ b/tests/http/collection/test_async_collection_client_create.py @@ -0,0 +1,26 @@ +import json + +import httpx +import pytest +import respx + + +@pytest.mark.asyncio +async def test_create_resource(async_collection_client): # noqa: WPS210 + resource_data = {"name": "Test Resource", "status": "active"} + new_resource_data = {"id": "new-resource-id", "name": "Test Resource", "status": "active"} + create_response = httpx.Response(201, json={"data": new_resource_data}) + + with respx.mock: + mock_route = respx.post("https://api.example.com/api/v1/test").mock( + return_value=create_response + ) + + created_resource = await async_collection_client.create(resource_data) + + assert created_resource.to_dict() == new_resource_data + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.method == "POST" + assert request.url == "https://api.example.com/api/v1/test" + assert json.loads(request.content.decode()) == resource_data diff --git a/tests/http/collection/test_async_collection_client_fetch.py b/tests/http/collection/test_async_collection_client_fetch.py new file mode 100644 index 00000000..3bcce0d7 --- /dev/null +++ b/tests/http/collection/test_async_collection_client_fetch.py @@ -0,0 +1,146 @@ +import httpx +import pytest +import respx + +from mpt_api_client.rql import RQLQuery + + +@pytest.fixture +def list_response(): + return httpx.Response(httpx.codes.OK, json={"data": [{"id": "ID-1"}]}) + + +@pytest.fixture +def single_result_response(): + return httpx.Response( + httpx.codes.OK, + json={ + "data": [{"id": "ID-1", "name": "Test Resource"}], + "$meta": {"pagination": {"total": 1, "offset": 0, "limit": 1}}, + }, + ) + + +@pytest.fixture +def no_results_response(): + return httpx.Response( + httpx.codes.OK, + json={"data": [], "$meta": {"pagination": {"total": 0, "offset": 0, "limit": 1}}}, + ) + + +@pytest.fixture +def multiple_results_response(): + return httpx.Response( + 200, + json={ + "data": [{"id": "ID-1", "name": "Resource 1"}, {"id": "ID-2", "name": "Resource 2"}], + "$meta": {"pagination": {"total": 2, "offset": 0, "limit": 1}}, + }, + ) + + +@pytest.fixture +def no_meta_response(): + return httpx.Response(httpx.codes.OK, json={"data": [{"id": "ID-1"}]}) + + +@pytest.fixture +def filter_status_active(): + return RQLQuery(status="active") + + +@pytest.mark.asyncio +async def test_fetch_one_success(async_collection_client, single_result_response): + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=single_result_response + ) + + resource = await async_collection_client.fetch_one() + + assert resource.id == "ID-1" + assert resource.name == "Test Resource" + assert mock_route.called + + first_request = mock_route.calls[0].request + assert "limit=1" in str(first_request.url) + assert "offset=0" in str(first_request.url) + + +@pytest.mark.asyncio +async def test_fetch_one_no_results(async_collection_client, no_results_response): + with respx.mock: + respx.get("https://api.example.com/api/v1/test").mock(return_value=no_results_response) + + with pytest.raises(ValueError, match="Expected one result, but got zero results"): + await async_collection_client.fetch_one() + + +@pytest.mark.asyncio +async def test_fetch_one_multiple_results(async_collection_client, multiple_results_response): + with respx.mock: + respx.get("https://api.example.com/api/v1/test").mock( + return_value=multiple_results_response + ) + + with pytest.raises(ValueError, match=r"Expected one result, but got 2 results"): + await async_collection_client.fetch_one() + + +@pytest.mark.asyncio +async def test_fetch_one_with_filters( + async_collection_client, single_result_response, filter_status_active +): + filtered_collection = ( + async_collection_client.filter(filter_status_active) + .select("id", "name") + .order_by("created") + ) + + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=single_result_response + ) + resource = await filtered_collection.fetch_one() + + assert resource.id == "ID-1" + assert mock_route.called + + first_request = mock_route.calls[0].request + assert first_request.method == "GET" + assert first_request.url == ( + "https://api.example.com/api/v1/test" + "?limit=1&offset=0&order=created" + "&select=id,name&eq(status,active)" + ) + + +@pytest.mark.asyncio +async def test_fetch_page_with_filter( + async_collection_client, list_response, filter_status_active +) -> None: + custom_collection = ( + async_collection_client.filter(filter_status_active) + .select("-audit", "product.agreements", "-product.agreements.product") + .order_by("-created", "name") + ) + + expected_url = ( + "https://api.example.com/api/v1/test?limit=10&offset=5" + "&order=-created,name" + "&select=-audit,product.agreements,-product.agreements.product" + "&eq(status,active)" + ) + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=list_response + ) + collection_results = await custom_collection.fetch_page(limit=10, offset=5) + + assert collection_results.to_list() == [{"id": "ID-1"}] + assert mock_route.called + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.method == "GET" + assert request.url == expected_url diff --git a/tests/http/collection/test_async_collection_client_init.py b/tests/http/collection/test_async_collection_client_init.py new file mode 100644 index 00000000..81e90ce5 --- /dev/null +++ b/tests/http/collection/test_async_collection_client_init.py @@ -0,0 +1,34 @@ +import pytest + +from mpt_api_client.http.client import HTTPClientAsync +from mpt_api_client.rql.query_builder import RQLQuery +from tests.http.conftest import DummyAsyncCollectionClientBase + + +@pytest.fixture +def mock_mpt_client_async(api_url, api_token): + return HTTPClientAsync(base_url=api_url, api_token=api_token) + + +@pytest.fixture +def sample_rql_query(): + return RQLQuery(status="active") + + +def test_init_defaults(async_collection_client): + assert async_collection_client.query_rql is None + assert async_collection_client.query_order_by is None + assert async_collection_client.query_select is None + assert async_collection_client.build_url() == "/api/v1/test" + + +def test_init_with_filter(http_client_async, sample_rql_query): + collection_client = DummyAsyncCollectionClientBase( + http_client=http_client_async, + query_rql=sample_rql_query, + ) + + assert collection_client.query_rql == sample_rql_query + assert collection_client.query_order_by is None + assert collection_client.query_select is None + assert collection_client.build_url() == "/api/v1/test?eq(status,active)" diff --git a/tests/http/collection/test_async_collection_client_iterate.py b/tests/http/collection/test_async_collection_client_iterate.py new file mode 100644 index 00000000..88024bf9 --- /dev/null +++ b/tests/http/collection/test_async_collection_client_iterate.py @@ -0,0 +1,141 @@ +import httpx +import pytest +import respx + +from mpt_api_client.rql import RQLQuery + + +@pytest.mark.asyncio +async def test_iterate_single_page(async_collection_client, single_page_response): + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=single_page_response + ) + + resources = [resource async for resource in async_collection_client.iterate()] + + request = mock_route.calls[0].request + + assert len(resources) == 2 + assert resources[0].to_dict() == {"id": "ID-1", "name": "Resource 1"} + assert resources[1].to_dict() == {"id": "ID-2", "name": "Resource 2"} + assert mock_route.call_count == 1 + assert request.url == "https://api.example.com/api/v1/test?limit=100&offset=0" + + +@pytest.mark.asyncio +async def test_iterate_multiple_pages( + async_collection_client, multi_page_response_page1, multi_page_response_page2 +): + with respx.mock: + respx.get("https://api.example.com/api/v1/test", params={"limit": 2, "offset": 0}).mock( + return_value=multi_page_response_page1 + ) + respx.get("https://api.example.com/api/v1/test", params={"limit": 2, "offset": 2}).mock( + return_value=multi_page_response_page2 + ) + + resources = [resource async for resource in async_collection_client.iterate(2)] + + assert len(resources) == 4 + assert resources[0].id == "ID-1" + assert resources[1].id == "ID-2" + assert resources[2].id == "ID-3" + assert resources[3].id == "ID-4" + + +@pytest.mark.asyncio +async def test_iterate_empty_results(async_collection_client, empty_response): + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=empty_response + ) + + resources = [resource async for resource in async_collection_client.iterate()] + + assert len(resources) == 0 + assert mock_route.call_count == 1 + + +@pytest.mark.asyncio +async def test_iterate_no_meta(async_collection_client, no_meta_response): + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=no_meta_response + ) + + resources = [resource async for resource in async_collection_client.iterate()] + + assert len(resources) == 2 + assert resources[0].id == "ID-1" + assert resources[1].id == "ID-2" + assert mock_route.call_count == 1 + + +@pytest.mark.asyncio +async def test_iterate_with_filters(async_collection_client): + filtered_collection = ( + async_collection_client.filter(RQLQuery(status="active")) + .select("id", "name") + .order_by("created") + ) + + response = httpx.Response( + httpx.codes.OK, + json={ + "data": [{"id": "ID-1", "name": "Active Resource"}], + "$meta": { + "pagination": { + "total": 1, + "offset": 0, + "limit": 100, + } + }, + }, + ) + + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock(return_value=response) + + resources = [resource async for resource in filtered_collection.iterate()] + + assert len(resources) == 1 + assert resources[0].id == "ID-1" + assert resources[0].name == "Active Resource" + + request = mock_route.calls[0].request + assert ( + str(request.url) == "https://api.example.com/api/v1/test" + "?limit=100&offset=0&order=created&select=id,name&eq(status,active)" + ) + + +@pytest.mark.asyncio +async def test_iterate_lazy_evaluation(async_collection_client): + response = httpx.Response( + httpx.codes.OK, + json={ + "data": [{"id": "ID-1", "name": "Resource 1"}], + "$meta": { + "pagination": { + "total": 1, + "offset": 0, + "limit": 100, + } + }, + }, + ) + + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock(return_value=response) + + iterator = async_collection_client.iterate() + + # No requests should be made until we start iterating + assert mock_route.call_count == 0 + + # Get first item to trigger the first request + first_resource = await anext(iterator) + + assert first_resource.id == "ID-1" + assert mock_route.call_count == 1 diff --git a/tests/http/collection/test_collection_client_iterate.py b/tests/http/collection/test_collection_client_iterate.py index 1b628791..2ce02117 100644 --- a/tests/http/collection/test_collection_client_iterate.py +++ b/tests/http/collection/test_collection_client_iterate.py @@ -5,96 +5,6 @@ from mpt_api_client.rql import RQLQuery -@pytest.fixture -def single_page_response(): - return httpx.Response( - httpx.codes.OK, - json={ - "data": [ - {"id": "ID-1", "name": "Resource 1"}, - {"id": "ID-2", "name": "Resource 2"}, - ], - "$meta": { - "pagination": { - "total": 2, - "offset": 0, - "limit": 100, - } - }, - }, - ) - - -@pytest.fixture -def multi_page_response_page1(): - return httpx.Response( - httpx.codes.OK, - json={ - "data": [ - {"id": "ID-1", "name": "Resource 1"}, - {"id": "ID-2", "name": "Resource 2"}, - ], - "$meta": { - "pagination": { - "total": 4, - "offset": 0, - "limit": 2, - } - }, - }, - ) - - -@pytest.fixture -def multi_page_response_page2(): - return httpx.Response( - httpx.codes.OK, - json={ - "data": [ - {"id": "ID-3", "name": "Resource 3"}, - {"id": "ID-4", "name": "Resource 4"}, - ], - "$meta": { - "pagination": { - "total": 4, - "offset": 2, - "limit": 2, - } - }, - }, - ) - - -@pytest.fixture -def empty_response(): - return httpx.Response( - httpx.codes.OK, - json={ - "data": [], - "$meta": { - "pagination": { - "total": 0, - "offset": 0, - "limit": 100, - } - }, - }, - ) - - -@pytest.fixture -def no_meta_response(): - return httpx.Response( - httpx.codes.OK, - json={ - "data": [ - {"id": "ID-1", "name": "Resource 1"}, - {"id": "ID-2", "name": "Resource 2"}, - ] - }, - ) - - def test_iterate_single_page(collection_client, single_page_response): with respx.mock: mock_route = respx.get("https://api.example.com/api/v1/test").mock( @@ -115,14 +25,14 @@ def test_iterate_multiple_pages( collection_client, multi_page_response_page1, multi_page_response_page2 ): with respx.mock: - respx.get("https://api.example.com/api/v1/test", params={"limit": 100, "offset": 0}).mock( + respx.get("https://api.example.com/api/v1/test", params={"limit": 2, "offset": 0}).mock( return_value=multi_page_response_page1 ) - respx.get("https://api.example.com/api/v1/test", params={"limit": 100, "offset": 2}).mock( + respx.get("https://api.example.com/api/v1/test", params={"limit": 2, "offset": 2}).mock( return_value=multi_page_response_page2 ) - resources = list(collection_client.iterate()) + resources = list(collection_client.iterate(2)) assert len(resources) == 4 assert resources[0].id == "ID-1" @@ -137,7 +47,7 @@ def test_iterate_empty_results(collection_client, empty_response): return_value=empty_response ) - resources = list(collection_client.iterate()) + resources = list(collection_client.iterate(2)) assert len(resources) == 0 assert mock_route.call_count == 1 diff --git a/tests/http/collection/test_collection_mixin.py b/tests/http/collection/test_collection_mixin.py index a833fe8f..b9529de1 100644 --- a/tests/http/collection/test_collection_mixin.py +++ b/tests/http/collection/test_collection_mixin.py @@ -71,8 +71,7 @@ def test_url(collection_client) -> None: def test_clone(collection_client) -> None: configured = ( - collection_client - .filter(RQLQuery(status="active")) + collection_client.filter(RQLQuery(status="active")) .order_by("created", "-name") .select("agreement", "-product") ) diff --git a/tests/http/conftest.py b/tests/http/conftest.py index d6b63e7f..58178f1c 100644 --- a/tests/http/conftest.py +++ b/tests/http/conftest.py @@ -1,7 +1,7 @@ import pytest from mpt_api_client.http.client import HTTPClient, HTTPClientAsync -from mpt_api_client.http.collection import CollectionClientBase +from mpt_api_client.http.collection import AsyncCollectionClientBase, CollectionClientBase from mpt_api_client.http.resource import ResourceBaseClient from mpt_api_client.models import Collection from tests.conftest import DummyResource @@ -19,6 +19,13 @@ class DummyCollectionClientBase(CollectionClientBase[DummyResource, DummyResourc _collection_class = Collection[DummyResource] +class DummyAsyncCollectionClientBase(AsyncCollectionClientBase[DummyResource, DummyResourceClient]): + _endpoint = "/api/v1/test" + _resource_class = DummyResource + _resource_client_class = DummyResourceClient + _collection_class = Collection[DummyResource] + + @pytest.fixture def api_url(): return "https://api.example.com" @@ -47,3 +54,8 @@ def resource_client(http_client): @pytest.fixture def collection_client(http_client) -> DummyCollectionClientBase: return DummyCollectionClientBase(http_client=http_client) + + +@pytest.fixture +def async_collection_client(http_client_async) -> DummyAsyncCollectionClientBase: + return DummyAsyncCollectionClientBase(http_client=http_client_async)