Skip to content

Commit c5bc557

Browse files
committed
Refactored to simplify Async implementation
- Unified ResourceClient and CollectionClient in Service - Renamed Resource Client to Service - Renamed Collection Client to Service - Renamed Resource to Model - Unified naming by adding Async as prefix to all Async implementation - Removed required custom collections in Services (CollectionClient) - Removed Resource Registry - Added Async Orders - Added Async MPT
1 parent ea71df4 commit c5bc557

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+1932
-2630
lines changed

mpt_api_client/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from mpt_api_client.mptclient import MPTClient
1+
from mpt_api_client.mpt_client import AsyncMPTClient, MPTClient
22
from mpt_api_client.rql import RQLQuery
33

4-
__all__ = ["MPTClient", "RQLQuery"] # noqa: WPS410
4+
__all__ = ["AsyncMPTClient", "MPTClient", "RQLQuery"] # noqa: WPS410

mpt_api_client/http/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from mpt_api_client.http.async_client import AsyncHTTPClient
2+
from mpt_api_client.http.async_service import AsyncService
3+
from mpt_api_client.http.client import HTTPClient
4+
from mpt_api_client.http.service import Service
5+
6+
__all__ = ["AsyncHTTPClient", "AsyncService", "HTTPClient", "Service"] # noqa: WPS410
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import os
2+
3+
from httpx import AsyncClient, AsyncHTTPTransport
4+
5+
6+
class AsyncHTTPClient(AsyncClient):
7+
"""Async HTTP client for interacting with SoftwareOne Marketplace Platform API."""
8+
9+
def __init__(
10+
self,
11+
*,
12+
base_url: str | None = None,
13+
api_token: str | None = None,
14+
timeout: float = 5.0,
15+
retries: int = 0,
16+
):
17+
api_token = api_token or os.getenv("MPT_TOKEN")
18+
if not api_token:
19+
raise ValueError(
20+
"API token is required. "
21+
"Set it up as env variable MPT_TOKEN or pass it as `api_token` "
22+
"argument to MPTClient."
23+
)
24+
25+
base_url = base_url or os.getenv("MPT_URL")
26+
if not base_url:
27+
raise ValueError(
28+
"Base URL is required. "
29+
"Set it up as env variable MPT_URL or pass it as `base_url` "
30+
"argument to MPTClient."
31+
)
32+
base_headers = {
33+
"User-Agent": "swo-marketplace-client/1.0",
34+
"Authorization": f"Bearer {api_token}",
35+
}
36+
super().__init__(
37+
base_url=base_url,
38+
headers=base_headers,
39+
timeout=timeout,
40+
transport=AsyncHTTPTransport(retries=retries),
41+
)
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
from collections.abc import AsyncIterator
2+
from urllib.parse import urljoin
3+
4+
import httpx
5+
6+
from mpt_api_client.http.async_client import AsyncHTTPClient
7+
from mpt_api_client.http.base_service import ServiceBase
8+
from mpt_api_client.models import Collection, ResourceData
9+
from mpt_api_client.models import Model as BaseModel
10+
from mpt_api_client.models.collection import ResourceList
11+
12+
13+
class AsyncService[Model: BaseModel](ServiceBase[AsyncHTTPClient, Model]): # noqa: WPS214
14+
"""Immutable Service for RESTful resource collections.
15+
16+
Examples:
17+
active_orders_cc = order_collection.filter(RQLQuery(status="active"))
18+
active_orders = active_orders_cc.order_by("created").iterate()
19+
product_active_orders = active_orders_cc.filter(RQLQuery(product__id="PRD-1")).iterate()
20+
21+
new_order = order_collection.create(order_data)
22+
23+
"""
24+
25+
async def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[Model]:
26+
"""Fetch one page of resources."""
27+
response = await self._fetch_page_as_response(limit=limit, offset=offset)
28+
return self._create_collection(response)
29+
30+
async def fetch_one(self) -> Model:
31+
"""Fetch one resource, expect exactly one result.
32+
33+
Returns:
34+
One resource.
35+
36+
Raises:
37+
ValueError: If the total matching records are not exactly one.
38+
"""
39+
response = await self._fetch_page_as_response(limit=1, offset=0)
40+
resource_list = self._create_collection(response)
41+
total_records = len(resource_list)
42+
if resource_list.meta:
43+
total_records = resource_list.meta.pagination.total
44+
if total_records == 0:
45+
raise ValueError("Expected one result, but got zero results")
46+
if total_records > 1:
47+
raise ValueError(f"Expected one result, but got {total_records} results")
48+
49+
return resource_list[0]
50+
51+
async def iterate(self, batch_size: int = 100) -> AsyncIterator[Model]:
52+
"""Iterate over all resources, yielding GenericResource objects.
53+
54+
Args:
55+
batch_size: Number of resources to fetch per request
56+
57+
Returns:
58+
Iterator of resources.
59+
"""
60+
offset = 0
61+
limit = batch_size # Default page size
62+
63+
while True:
64+
response = await self._fetch_page_as_response(limit=limit, offset=offset)
65+
items_collection = self._create_collection(response)
66+
for resource in items_collection:
67+
yield resource
68+
69+
if not items_collection.meta:
70+
break
71+
if not items_collection.meta.pagination.has_next():
72+
break
73+
offset = items_collection.meta.pagination.next_offset()
74+
75+
async def create(self, resource_data: ResourceData) -> Model:
76+
"""Create a new resource using `POST /endpoint`.
77+
78+
Returns:
79+
New resource created.
80+
"""
81+
response = await self.http_client.post(self._endpoint, json=resource_data)
82+
response.raise_for_status()
83+
84+
return self._model_class.from_response(response)
85+
86+
async def get(self, resource_id: str) -> Model:
87+
"""Fetch a specific resource using `GET /endpoint/{resource_id}`."""
88+
return await self._resource_action(resource_id=resource_id)
89+
90+
async def update(self, resource_id: str, resource_data: ResourceData) -> Model:
91+
"""Update a resource using `PUT /endpoint/{resource_id}`."""
92+
return await self._resource_action(resource_id, "PUT", json=resource_data)
93+
94+
async def delete(self, resource_id: str) -> None:
95+
"""Delete resource using `DELETE /endpoint/{resource_id}`."""
96+
url = urljoin(f"{self._endpoint}/", resource_id)
97+
response = await self.http_client.delete(url)
98+
response.raise_for_status()
99+
100+
async def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Response:
101+
"""Fetch one page of resources.
102+
103+
Returns:
104+
httpx.Response object.
105+
106+
Raises:
107+
HTTPStatusError: if the response status code is not 200.
108+
"""
109+
pagination_params: dict[str, int] = {"limit": limit, "offset": offset}
110+
response = await self.http_client.get(self.build_url(pagination_params))
111+
response.raise_for_status()
112+
113+
return response
114+
115+
async def _resource_do_request(
116+
self,
117+
resource_id: str,
118+
method: str = "GET",
119+
action: str | None = None,
120+
json: ResourceData | ResourceList | None = None,
121+
) -> httpx.Response:
122+
"""Perform an action on a specific resource using.
123+
124+
Request with action: `HTTP_METHOD /endpoint/{resource_id}/{action}`.
125+
Request without action: `HTTP_METHOD /endpoint/{resource_id}`.
126+
127+
Args:
128+
resource_id: The resource ID to operate on.
129+
method: The HTTP method to use.
130+
action: The action name to use.
131+
json: The updated resource data.
132+
133+
Raises:
134+
HTTPError: If the action fails.
135+
"""
136+
resource_url = urljoin(f"{self._endpoint}/", resource_id)
137+
url = urljoin(f"{resource_url}/", action) if action else resource_url
138+
response = await self.http_client.request(method, url, json=json)
139+
response.raise_for_status()
140+
return response
141+
142+
async def _resource_action(
143+
self,
144+
resource_id: str,
145+
method: str = "GET",
146+
action: str | None = None,
147+
json: ResourceData | ResourceList | None = None,
148+
) -> Model:
149+
"""Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`."""
150+
response = await self._resource_do_request(resource_id, method, action, json=json)
151+
return self._model_class.from_response(response)
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import copy
2+
from typing import Any, Self
3+
4+
import httpx
5+
6+
from mpt_api_client.models import Collection, Meta
7+
from mpt_api_client.models import Model as BaseModel
8+
from mpt_api_client.rql import RQLQuery
9+
10+
11+
class ServiceBase[Client, Model: BaseModel]:
12+
"""Service base with agnostic HTTP client."""
13+
14+
_endpoint: str
15+
_model_class: type[Model]
16+
_collection_key = "data"
17+
18+
def __init__(
19+
self,
20+
*,
21+
http_client: Client,
22+
query_rql: RQLQuery | None = None,
23+
query_order_by: list[str] | None = None,
24+
query_select: list[str] | None = None,
25+
) -> None:
26+
self.http_client = http_client
27+
self.query_rql: RQLQuery | None = query_rql
28+
self.query_order_by = query_order_by
29+
self.query_select = query_select
30+
31+
def clone(self) -> Self:
32+
"""Create a copy of collection client for immutable operations.
33+
34+
Returns:
35+
New collection client with same settings.
36+
"""
37+
return type(self)(
38+
http_client=self.http_client,
39+
query_rql=self.query_rql,
40+
query_order_by=copy.copy(self.query_order_by) if self.query_order_by else None,
41+
query_select=copy.copy(self.query_select) if self.query_select else None,
42+
)
43+
44+
def build_url(self, query_params: dict[str, Any] | None = None) -> str: # noqa: WPS210
45+
"""Builds the endpoint URL with all the query parameters.
46+
47+
Returns:
48+
Partial URL with query parameters.
49+
"""
50+
query_params = query_params or {}
51+
query_parts = [
52+
f"{param_key}={param_value}" for param_key, param_value in query_params.items()
53+
]
54+
if self.query_order_by:
55+
str_order_by = ",".join(self.query_order_by)
56+
query_parts.append(f"order={str_order_by}")
57+
if self.query_select:
58+
str_query_select = ",".join(self.query_select)
59+
query_parts.append(f"select={str_query_select}")
60+
if self.query_rql:
61+
query_parts.append(str(self.query_rql))
62+
if query_parts:
63+
query = "&".join(query_parts)
64+
return f"{self._endpoint}?{query}"
65+
return self._endpoint
66+
67+
def order_by(self, *fields: str) -> Self:
68+
"""Returns new collection with ordering setup.
69+
70+
Returns:
71+
New collection with ordering setup.
72+
73+
Raises:
74+
ValueError: If ordering has already been set.
75+
"""
76+
if self.query_order_by is not None:
77+
raise ValueError("Ordering is already set. Cannot set ordering multiple times.")
78+
new_collection = self.clone()
79+
new_collection.query_order_by = list(fields)
80+
return new_collection
81+
82+
def filter(self, rql: RQLQuery) -> Self:
83+
"""Creates a new collection with the filter added to the filter collection.
84+
85+
Returns:
86+
New copy of the collection with the filter added.
87+
"""
88+
if self.query_rql:
89+
rql = self.query_rql & rql
90+
new_collection = self.clone()
91+
new_collection.query_rql = rql
92+
return new_collection
93+
94+
def select(self, *fields: str) -> Self:
95+
"""Set select fields. Raises ValueError if select fields are already set.
96+
97+
Returns:
98+
New copy of the collection with the select fields set.
99+
100+
Raises:
101+
ValueError: If select fields are already set.
102+
"""
103+
if self.query_select is not None:
104+
raise ValueError(
105+
"Select fields are already set. Cannot set select fields multiple times."
106+
)
107+
108+
new_client = self.clone()
109+
new_client.query_select = list(fields)
110+
return new_client
111+
112+
def _create_collection(self, response: httpx.Response) -> Collection[Model]:
113+
meta = Meta.from_response(response)
114+
return Collection(
115+
resources=[
116+
self._model_class.new(resource, meta)
117+
for resource in response.json().get(self._collection_key)
118+
],
119+
meta=meta,
120+
)

mpt_api_client/http/client.py

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

3-
from httpx import AsyncClient, AsyncHTTPTransport, Client, HTTPTransport
3+
from httpx import Client, HTTPTransport
44

55

66
class HTTPClient(Client):
@@ -33,49 +33,9 @@ def __init__(
3333
"User-Agent": "swo-marketplace-client/1.0",
3434
"Authorization": f"Bearer {api_token}",
3535
}
36-
Client.__init__(
37-
self,
36+
super().__init__(
3837
base_url=base_url,
3938
headers=base_headers,
4039
timeout=timeout,
4140
transport=HTTPTransport(retries=retries),
4241
)
43-
44-
45-
class HTTPClientAsync(AsyncClient):
46-
"""Async HTTP client for interacting with SoftwareOne Marketplace Platform API."""
47-
48-
def __init__(
49-
self,
50-
*,
51-
base_url: str | None = None,
52-
api_token: str | None = None,
53-
timeout: float = 5.0,
54-
retries: int = 0,
55-
):
56-
api_token = api_token or os.getenv("MPT_TOKEN")
57-
if not api_token:
58-
raise ValueError(
59-
"API token is required. "
60-
"Set it up as env variable MPT_TOKEN or pass it as `api_token` "
61-
"argument to MPTClient."
62-
)
63-
64-
base_url = base_url or os.getenv("MPT_URL")
65-
if not base_url:
66-
raise ValueError(
67-
"Base URL is required. "
68-
"Set it up as env variable MPT_URL or pass it as `base_url` "
69-
"argument to MPTClient."
70-
)
71-
base_headers = {
72-
"User-Agent": "swo-marketplace-client/1.0",
73-
"Authorization": f"Bearer {api_token}",
74-
}
75-
AsyncClient.__init__(
76-
self,
77-
base_url=base_url,
78-
headers=base_headers,
79-
timeout=timeout,
80-
transport=AsyncHTTPTransport(retries=retries),
81-
)

0 commit comments

Comments
 (0)