Skip to content

Commit 9f1c9c7

Browse files
authored
MPT-12358 Collection client async (#19)
2 parents c725640 + 3e07b4a commit 9f1c9c7

18 files changed

Lines changed: 668 additions & 162 deletions

mpt_api_client/http/collection.py

Lines changed: 155 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,43 @@
11
import copy
22
from abc import ABC
3-
from collections.abc import Iterator
3+
from collections.abc import AsyncIterator, Iterator
44
from typing import Any, Self
55

66
import httpx
77

8-
from mpt_api_client.http.client import HTTPClient
8+
from mpt_api_client.http.client import HTTPClient, HTTPClientAsync
99
from mpt_api_client.http.resource import ResourceBaseClient
1010
from mpt_api_client.models import Collection, Resource
1111
from mpt_api_client.rql.query_builder import RQLQuery
1212

1313

14-
class CollectionBaseClient[ResourceModel: Resource, ResourceClient: ResourceBaseClient[Resource]]( # noqa: WPS214
15-
ABC
16-
):
17-
"""Immutable Base client for RESTful resource collections.
18-
19-
Examples:
20-
active_orders_cc = order_collection.filter(RQLQuery(status="active"))
21-
active_orders = active_orders_cc.order_by("created").iterate()
22-
product_active_orders = active_orders_cc.filter(RQLQuery(product__id="PRD-1")).iterate()
23-
24-
new_order = order_collection.create(order_data)
25-
26-
"""
14+
class CollectionMixin:
15+
"""Mixin for collection clients."""
2716

2817
_endpoint: str
29-
_resource_class: type[ResourceModel]
30-
_resource_client_class: type[ResourceClient]
31-
_collection_class: type[Collection[ResourceModel]]
18+
_resource_class: type[Any]
19+
_resource_client_class: type[Any]
20+
_collection_class: type[Collection[Any]]
3221

3322
def __init__(
3423
self,
24+
http_client: HTTPClient | HTTPClientAsync,
3525
query_rql: RQLQuery | None = None,
36-
client: HTTPClient | None = None,
3726
) -> None:
38-
self.mpt_client = client or HTTPClient()
27+
self.http_client = http_client
3928
self.query_rql: RQLQuery | None = query_rql
4029
self.query_order_by: list[str] | None = None
4130
self.query_select: list[str] | None = None
4231

4332
@classmethod
44-
def clone(
45-
cls, collection_client: "CollectionBaseClient[ResourceModel, ResourceClient]"
46-
) -> Self:
33+
def clone(cls, collection_client: "CollectionMixin") -> Self:
4734
"""Create a copy of collection client for immutable operations.
4835
4936
Returns:
5037
New collection client with same settings.
5138
"""
5239
new_collection = cls(
53-
client=collection_client.mpt_client,
40+
http_client=collection_client.http_client,
5441
query_rql=collection_client.query_rql,
5542
)
5643
new_collection.query_order_by = (
@@ -128,6 +115,33 @@ def select(self, *fields: str) -> Self:
128115
new_client.query_select = list(fields)
129116
return new_client
130117

118+
119+
class CollectionClientBase[ResourceModel: Resource, ResourceClient: ResourceBaseClient[Resource]]( # noqa: WPS214
120+
ABC, CollectionMixin
121+
):
122+
"""Immutable Base client for RESTful resource collections.
123+
124+
Examples:
125+
active_orders_cc = order_collection.filter(RQLQuery(status="active"))
126+
active_orders = active_orders_cc.order_by("created").iterate()
127+
product_active_orders = active_orders_cc.filter(RQLQuery(product__id="PRD-1")).iterate()
128+
129+
new_order = order_collection.create(order_data)
130+
131+
"""
132+
133+
_resource_class: type[ResourceModel]
134+
_resource_client_class: type[ResourceClient]
135+
_collection_class: type[Collection[ResourceModel]]
136+
137+
def __init__(
138+
self,
139+
query_rql: RQLQuery | None = None,
140+
http_client: HTTPClient | None = None,
141+
) -> None:
142+
self.http_client: HTTPClient = http_client or HTTPClient() # type: ignore[mutable-override]
143+
CollectionMixin.__init__(self, http_client=self.http_client, query_rql=query_rql)
144+
131145
def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[ResourceModel]:
132146
"""Fetch one page of resources.
133147
@@ -185,15 +199,15 @@ def iterate(self, batch_size: int = 100) -> Iterator[ResourceModel]:
185199

186200
def get(self, resource_id: str) -> ResourceClient:
187201
"""Get resource by resource_id."""
188-
return self._resource_client_class(client=self.mpt_client, resource_id=resource_id)
202+
return self._resource_client_class(http_client=self.http_client, resource_id=resource_id)
189203

190204
def create(self, resource_data: dict[str, Any]) -> ResourceModel:
191205
"""Create a new resource using `POST /endpoint`.
192206
193207
Returns:
194208
New resource created.
195209
"""
196-
response = self.mpt_client.post(self._endpoint, json=resource_data)
210+
response = self.http_client.post(self._endpoint, json=resource_data)
197211
response.raise_for_status()
198212

199213
return self._resource_class.from_response(response)
@@ -208,7 +222,121 @@ def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Re
208222
HTTPStatusError: if the response status code is not 200.
209223
"""
210224
pagination_params: dict[str, int] = {"limit": limit, "offset": offset}
211-
response = self.mpt_client.get(self.build_url(pagination_params))
225+
response = self.http_client.get(self.build_url(pagination_params))
226+
response.raise_for_status()
227+
228+
return response
229+
230+
231+
class AsyncCollectionClientBase[
232+
ResourceModel: Resource,
233+
ResourceClient: ResourceBaseClient[Resource],
234+
](ABC, CollectionMixin):
235+
"""Immutable Base client for RESTful resource collections.
236+
237+
Examples:
238+
active_orders_cc = order_collection.filter(RQLQuery(status="active"))
239+
active_orders = active_orders_cc.order_by("created").iterate()
240+
product_active_orders = active_orders_cc.filter(RQLQuery(product__id="PRD-1")).iterate()
241+
242+
new_order = order_collection.create(order_data)
243+
244+
"""
245+
246+
_resource_class: type[ResourceModel]
247+
_resource_client_class: type[ResourceClient]
248+
_collection_class: type[Collection[ResourceModel]]
249+
250+
def __init__(
251+
self,
252+
query_rql: RQLQuery | None = None,
253+
http_client: HTTPClientAsync | None = None,
254+
) -> None:
255+
self.http_client: HTTPClientAsync = http_client or HTTPClientAsync() # type: ignore[mutable-override]
256+
CollectionMixin.__init__(self, http_client=self.http_client, query_rql=query_rql)
257+
258+
async def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[ResourceModel]:
259+
"""Fetch one page of resources.
260+
261+
Returns:
262+
Collection of resources.
263+
"""
264+
response = await self._fetch_page_as_response(limit=limit, offset=offset)
265+
return Collection.from_response(response)
266+
267+
async def fetch_one(self) -> ResourceModel:
268+
"""Fetch one page, expect exactly one result.
269+
270+
Returns:
271+
One resource.
272+
273+
Raises:
274+
ValueError: If the total matching records are not exactly one.
275+
"""
276+
response = await self._fetch_page_as_response(limit=1, offset=0)
277+
resource_list: Collection[ResourceModel] = Collection.from_response(response)
278+
total_records = len(resource_list)
279+
if resource_list.meta:
280+
total_records = resource_list.meta.pagination.total
281+
if total_records == 0:
282+
raise ValueError("Expected one result, but got zero results")
283+
if total_records > 1:
284+
raise ValueError(f"Expected one result, but got {total_records} results")
285+
286+
return resource_list[0]
287+
288+
async def iterate(self, batch_size: int = 100) -> AsyncIterator[ResourceModel]:
289+
"""Iterate over all resources, yielding GenericResource objects.
290+
291+
Args:
292+
batch_size: Number of resources to fetch per request
293+
294+
Returns:
295+
Iterator of resources.
296+
"""
297+
offset = 0
298+
limit = batch_size # Default page size
299+
300+
while True:
301+
response = await self._fetch_page_as_response(limit=limit, offset=offset)
302+
items_collection: Collection[ResourceModel] = self._collection_class.from_response(
303+
response
304+
)
305+
for resource in items_collection:
306+
yield resource
307+
308+
if not items_collection.meta:
309+
break
310+
if not items_collection.meta.pagination.has_next():
311+
break
312+
offset = items_collection.meta.pagination.next_offset()
313+
314+
async def get(self, resource_id: str) -> ResourceClient:
315+
"""Get resource by resource_id."""
316+
return self._resource_client_class(http_client=self.http_client, resource_id=resource_id) # type: ignore[arg-type]
317+
318+
async def create(self, resource_data: dict[str, Any]) -> ResourceModel:
319+
"""Create a new resource using `POST /endpoint`.
320+
321+
Returns:
322+
New resource created.
323+
"""
324+
response = await self.http_client.post(self._endpoint, json=resource_data)
325+
response.raise_for_status()
326+
327+
return self._resource_class.from_response(response)
328+
329+
async def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Response:
330+
"""Fetch one page of resources.
331+
332+
Returns:
333+
httpx.Response object.
334+
335+
Raises:
336+
HTTPStatusError: if the response status code is not 200.
337+
"""
338+
pagination_params: dict[str, int] = {"limit": limit, "offset": offset}
339+
response = await self.http_client.get(self.build_url(pagination_params))
212340
response.raise_for_status()
213341

214342
return response

mpt_api_client/http/resource.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ class ResourceBaseClient[ResourceModel: Resource](ABC): # noqa: WPS214
1212

1313
_endpoint: str
1414
_resource_class: type[ResourceModel]
15-
_safe_attributes: ClassVar[set[str]] = {"mpt_client_", "resource_id_", "resource_"}
15+
_safe_attributes: ClassVar[set[str]] = {"http_client_", "resource_id_", "resource_"}
1616

17-
def __init__(self, client: HTTPClient, resource_id: str) -> None:
18-
self.mpt_client_ = client # noqa: WPS120
17+
def __init__(self, http_client: HTTPClient, resource_id: str) -> None:
18+
self.http_client_ = http_client # noqa: WPS120
1919
self.resource_id_ = resource_id # noqa: WPS120
2020
self.resource_: Resource | None = None # noqa: WPS120
2121

@@ -78,7 +78,7 @@ def do_action(
7878
HTTPError: If the action fails.
7979
"""
8080
url = f"{self.resource_url}/{url}" if url else self.resource_url
81-
response = self.mpt_client_.request(method, url, json=json)
81+
response = self.http_client_.request(method, url, json=json)
8282
response.raise_for_status()
8383
return response
8484

mpt_api_client/mptclient.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from mpt_api_client.http.client import HTTPClient
22
from mpt_api_client.registry import Registry, commerce
3-
from mpt_api_client.resources import OrderCollectionClient
3+
from mpt_api_client.resources import OrderCollectionClientBase
44

55

66
class MPTClientBase:
@@ -11,13 +11,13 @@ def __init__(
1111
base_url: str | None = None,
1212
api_key: str | None = None,
1313
registry: Registry | None = None,
14-
mpt_client: HTTPClient | None = None,
14+
http_client: HTTPClient | None = None,
1515
):
16-
self.mpt_client = mpt_client or HTTPClient(base_url=base_url, api_token=api_key)
16+
self.http_client = http_client or HTTPClient(base_url=base_url, api_token=api_key)
1717
self.registry: Registry = registry or Registry()
1818

1919
def __getattr__(self, name): # type: ignore[no-untyped-def]
20-
return self.registry.get(name)(client=self.mpt_client)
20+
return self.registry.get(name)(http_client=self.http_client)
2121

2222

2323
class MPTClient(MPTClientBase):
@@ -31,14 +31,14 @@ def commerce(self) -> "CommerceMpt":
3131
for managing agreements, requests, subscriptions, and orders
3232
within a vendor-client-ops ecosystem.
3333
"""
34-
return CommerceMpt(mpt_client=self.mpt_client, registry=commerce)
34+
return CommerceMpt(http_client=self.http_client, registry=commerce)
3535

3636

3737
class CommerceMpt(MPTClientBase):
3838
"""Commerce MPT API Client."""
3939

4040
@property
41-
def orders(self) -> OrderCollectionClient:
41+
def orders(self) -> OrderCollectionClientBase:
4242
"""Orders MPT API collection.
4343
4444
The Orders API provides a comprehensive set of endpoints
@@ -54,4 +54,4 @@ def orders(self) -> OrderCollectionClient:
5454
[...]
5555
5656
"""
57-
return self.registry.get("orders")(client=self.mpt_client) # type: ignore[return-value]
57+
return self.registry.get("orders")(http_client=self.http_client) # type: ignore[return-value]

mpt_api_client/registry.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from collections.abc import Callable
22
from typing import Any
33

4-
from mpt_api_client.http.collection import CollectionBaseClient
4+
from mpt_api_client.http.collection import CollectionClientBase
55

6-
ItemType = type[CollectionBaseClient[Any, Any]]
6+
ItemType = type[CollectionClientBase[Any, Any]]
77

88

99
class Registry:
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
from mpt_api_client.resources.order import Order, OrderCollectionClient, OrderResourceClient
1+
from mpt_api_client.resources.order import Order, OrderCollectionClientBase, OrderResourceClient
22

3-
__all__ = ["Order", "OrderCollectionClient", "OrderResourceClient"] # noqa: WPS410
3+
__all__ = ["Order", "OrderCollectionClientBase", "OrderResourceClient"] # noqa: WPS410

mpt_api_client/resources/order.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Any
22

3-
from mpt_api_client.http.collection import CollectionBaseClient
3+
from mpt_api_client.http.collection import CollectionClientBase
44
from mpt_api_client.http.resource import ResourceBaseClient
55
from mpt_api_client.models import Collection, Resource
66
from mpt_api_client.registry import commerce
@@ -76,7 +76,7 @@ def template(self) -> str:
7676

7777

7878
@commerce("orders")
79-
class OrderCollectionClient(CollectionBaseClient[Order, OrderResourceClient]):
79+
class OrderCollectionClientBase(CollectionClientBase[Order, OrderResourceClient]):
8080
"""Orders client."""
8181

8282
_endpoint = "/public/v1/commerce/orders"

0 commit comments

Comments
 (0)