Skip to content

Commit ea71df4

Browse files
committed
MPT-12359 Resource client async
1 parent 9f1c9c7 commit ea71df4

File tree

13 files changed

+521
-154
lines changed

13 files changed

+521
-154
lines changed

mpt_api_client/http/collection.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
import httpx
77

88
from mpt_api_client.http.client import HTTPClient, HTTPClientAsync
9-
from mpt_api_client.http.resource import ResourceBaseClient
9+
from mpt_api_client.http.resource import AsyncResourceBaseClient, ResourceBaseClient
1010
from mpt_api_client.models import Collection, Resource
11+
from mpt_api_client.models.base import ResourceData
1112
from mpt_api_client.rql.query_builder import RQLQuery
1213

1314

@@ -50,7 +51,7 @@ def clone(cls, collection_client: "CollectionMixin") -> Self:
5051
)
5152
return new_collection
5253

53-
def build_url(self, query_params: dict[str, Any] | None = None) -> str:
54+
def build_url(self, query_params: dict[str, Any] | None = None) -> str: # noqa: WPS210
5455
"""Builds the endpoint URL with all the query parameters.
5556
5657
Returns:
@@ -59,15 +60,18 @@ def build_url(self, query_params: dict[str, Any] | None = None) -> str:
5960
query_params = query_params or {}
6061
query_parts = [
6162
f"{param_key}={param_value}" for param_key, param_value in query_params.items()
62-
] # noqa: WPS237
63+
]
6364
if self.query_order_by:
64-
query_parts.append(f"order={','.join(self.query_order_by)}") # noqa: WPS237
65+
str_order_by = ",".join(self.query_order_by)
66+
query_parts.append(f"order={str_order_by}")
6567
if self.query_select:
66-
query_parts.append(f"select={','.join(self.query_select)}") # noqa: WPS237
68+
str_query_select = ",".join(self.query_select)
69+
query_parts.append(f"select={str_query_select}")
6770
if self.query_rql:
6871
query_parts.append(str(self.query_rql))
6972
if query_parts:
70-
return f"{self._endpoint}?{'&'.join(query_parts)}" # noqa: WPS237
73+
query = "&".join(query_parts)
74+
return f"{self._endpoint}?{query}"
7175
return self._endpoint
7276

7377
def order_by(self, *fields: str) -> Self:
@@ -201,7 +205,7 @@ def get(self, resource_id: str) -> ResourceClient:
201205
"""Get resource by resource_id."""
202206
return self._resource_client_class(http_client=self.http_client, resource_id=resource_id)
203207

204-
def create(self, resource_data: dict[str, Any]) -> ResourceModel:
208+
def create(self, resource_data: ResourceData) -> ResourceModel:
205209
"""Create a new resource using `POST /endpoint`.
206210
207211
Returns:
@@ -230,7 +234,7 @@ def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Re
230234

231235
class AsyncCollectionClientBase[
232236
ResourceModel: Resource,
233-
ResourceClient: ResourceBaseClient[Resource],
237+
ResourceClient: AsyncResourceBaseClient[Resource],
234238
](ABC, CollectionMixin):
235239
"""Immutable Base client for RESTful resource collections.
236240
@@ -313,7 +317,7 @@ async def iterate(self, batch_size: int = 100) -> AsyncIterator[ResourceModel]:
313317

314318
async def get(self, resource_id: str) -> ResourceClient:
315319
"""Get resource by resource_id."""
316-
return self._resource_client_class(http_client=self.http_client, resource_id=resource_id) # type: ignore[arg-type]
320+
return self._resource_client_class(http_client=self.http_client, resource_id=resource_id)
317321

318322
async def create(self, resource_data: dict[str, Any]) -> ResourceModel:
319323
"""Create a new resource using `POST /endpoint`.

mpt_api_client/http/resource.py

Lines changed: 161 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,31 @@
11
from abc import ABC
22
from typing import Any, ClassVar, Self, override
3+
from urllib.parse import urljoin
34

45
from httpx import Response
56

6-
from mpt_api_client.http.client import HTTPClient
7+
from mpt_api_client.http.client import HTTPClient, HTTPClientAsync
78
from mpt_api_client.models import Resource
9+
from mpt_api_client.models.base import ResourceData, ResourceList
810

911

10-
class ResourceBaseClient[ResourceModel: Resource](ABC): # noqa: WPS214
11-
"""Client for RESTful resources."""
12+
class ResourceMixin:
13+
"""Mixin for resource clients."""
1214

1315
_endpoint: str
14-
_resource_class: type[ResourceModel]
16+
_resource_class: type[Any]
1517
_safe_attributes: ClassVar[set[str]] = {"http_client_", "resource_id_", "resource_"}
1618

17-
def __init__(self, http_client: HTTPClient, resource_id: str) -> None:
18-
self.http_client_ = http_client # noqa: WPS120
19-
self.resource_id_ = resource_id # noqa: WPS120
20-
self.resource_: Resource | None = None # noqa: WPS120
19+
def __init__(
20+
self, http_client: HTTPClient | HTTPClientAsync, resource_id: str, resource: Resource | None
21+
) -> None:
22+
self.http_client_ = http_client
23+
self.resource_id_ = resource_id
24+
self.resource_: Resource | None = resource
2125

2226
def __getattr__(self, attribute: str) -> Any:
2327
"""Returns the resource data."""
24-
self._ensure_resource_is_fetched()
28+
self._assert_resource_is_set()
2529
return self.resource_.__getattr__(attribute) # type: ignore[union-attr]
2630

2731
@property
@@ -34,9 +38,33 @@ def __setattr__(self, attribute: str, attribute_value: Any) -> None:
3438
if attribute in self._safe_attributes:
3539
object.__setattr__(self, attribute, attribute_value)
3640
return
37-
self._ensure_resource_is_fetched()
41+
self._assert_resource_is_set()
3842
self.resource_.__setattr__(attribute, attribute_value)
3943

44+
def _assert_resource_is_set(self) -> None:
45+
if not self.resource_:
46+
class_name = self._resource_class.__name__
47+
raise RuntimeError(
48+
f"Resource data not available. Call fetch() method first to retrieve"
49+
f" the resource `{class_name}`"
50+
)
51+
52+
53+
class ResourceBaseClient[ResourceModel: Resource](ABC, ResourceMixin):
54+
"""Client for RESTful resources."""
55+
56+
_endpoint: str
57+
_resource_class: type[ResourceModel]
58+
_safe_attributes: ClassVar[set[str]] = {"http_client_", "resource_id_", "resource_"}
59+
60+
def __init__(
61+
self, http_client: HTTPClient, resource_id: str, resource: Resource | None = None
62+
) -> None:
63+
self.http_client_: HTTPClient = http_client # type: ignore[mutable-override]
64+
ResourceMixin.__init__(
65+
self, http_client=http_client, resource_id=resource_id, resource=resource
66+
)
67+
4068
def fetch(self) -> ResourceModel:
4169
"""Fetch a specific resource using `GET /endpoint/{resource_id}`.
4270
@@ -45,27 +73,74 @@ def fetch(self) -> ResourceModel:
4573
Returns:
4674
The fetched resource.
4775
"""
48-
response = self.do_action("GET")
76+
response = self._do_action("GET")
4977

50-
self.resource_ = self._resource_class.from_response(response) # noqa: WPS120
78+
self.resource_ = self._resource_class.from_response(response)
5179
return self.resource_
5280

53-
def resource_action(
81+
def update(self, resource_data: ResourceData) -> ResourceModel:
82+
"""Update a specific in the API and catches the result as a current resource.
83+
84+
Args:
85+
resource_data: The updated resource data.
86+
87+
Returns:
88+
The updated resource.
89+
90+
Examples:
91+
updated_contact = contact.update({"name": "New Name"})
92+
93+
94+
"""
95+
response = self._do_action("PUT", json=resource_data)
96+
self.resource_ = self._resource_class.from_response(response)
97+
return self.resource_
98+
99+
def save(self) -> Self:
100+
"""Save the current state of the resource to the api using the update method.
101+
102+
Raises:
103+
ValueError: If the resource has not been set.
104+
105+
Examples:
106+
contact.name = "New Name"
107+
contact.save()
108+
109+
"""
110+
self._assert_resource_is_set()
111+
self.update(self.resource_.to_dict()) # type: ignore[union-attr]
112+
return self
113+
114+
def delete(self) -> None:
115+
"""Delete the resource using `DELETE /endpoint/{resource_id}`.
116+
117+
Raises:
118+
HTTPStatusError: If the deletion fails.
119+
120+
Examples:
121+
contact.delete()
122+
"""
123+
response = self._do_action("DELETE")
124+
response.raise_for_status()
125+
126+
self.resource_ = None
127+
128+
def _resource_action(
54129
self,
55130
method: str = "GET",
56131
url: str | None = None,
57-
json: dict[str, Any] | list[Any] | None = None, # noqa: WPS221
132+
json: ResourceData | ResourceList | None = None,
58133
) -> ResourceModel:
59134
"""Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`."""
60-
response = self.do_action(method, url, json=json)
61-
self.resource_ = self._resource_class.from_response(response) # noqa: WPS120
135+
response = self._do_action(method, url, json=json)
136+
self.resource_ = self._resource_class.from_response(response)
62137
return self.resource_
63138

64-
def do_action(
139+
def _do_action(
65140
self,
66141
method: str = "GET",
67142
url: str | None = None,
68-
json: dict[str, Any] | list[Any] | None = None, # noqa: WPS221
143+
json: ResourceData | ResourceList | None = None,
69144
) -> Response:
70145
"""Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`.
71146
@@ -82,7 +157,36 @@ def do_action(
82157
response.raise_for_status()
83158
return response
84159

85-
def update(self, resource_data: dict[str, Any]) -> ResourceModel:
160+
161+
class AsyncResourceBaseClient[ResourceModel: Resource](ABC, ResourceMixin):
162+
"""Client for RESTful resources."""
163+
164+
_endpoint: str
165+
_resource_class: type[ResourceModel]
166+
_safe_attributes: ClassVar[set[str]] = {"http_client_", "resource_id_", "resource_"}
167+
168+
def __init__(
169+
self, http_client: HTTPClientAsync, resource_id: str, resource: Resource | None = None
170+
) -> None:
171+
self.http_client_: HTTPClientAsync = http_client # type: ignore[mutable-override]
172+
ResourceMixin.__init__(
173+
self, http_client=http_client, resource_id=resource_id, resource=resource
174+
)
175+
176+
async def fetch(self) -> ResourceModel:
177+
"""Fetch a specific resource using `GET /endpoint/{resource_id}`.
178+
179+
It fetches and caches the resource.
180+
181+
Returns:
182+
The fetched resource.
183+
"""
184+
response = await self._do_action("GET")
185+
186+
self.resource_ = self._resource_class.from_response(response)
187+
return self.resource_
188+
189+
async def update(self, resource_data: ResourceData) -> ResourceModel:
86190
"""Update a specific in the API and catches the result as a current resource.
87191
88192
Args:
@@ -96,11 +200,9 @@ def update(self, resource_data: dict[str, Any]) -> ResourceModel:
96200
97201
98202
"""
99-
response = self.do_action("PUT", json=resource_data)
100-
self.resource_ = self._resource_class.from_response(response) # noqa: WPS120
101-
return self.resource_
203+
return await self._resource_action("PUT", json=resource_data)
102204

103-
def save(self) -> Self:
205+
async def save(self) -> Self:
104206
"""Save the current state of the resource to the api using the update method.
105207
106208
Raises:
@@ -111,12 +213,11 @@ def save(self) -> Self:
111213
contact.save()
112214
113215
"""
114-
if not self.resource_:
115-
raise ValueError("Unable to save resource that has not been set.")
116-
self.update(self.resource_.to_dict())
216+
self._assert_resource_is_set()
217+
await self.update(self.resource_.to_dict()) # type: ignore[union-attr]
117218
return self
118219

119-
def delete(self) -> None:
220+
async def delete(self) -> None:
120221
"""Delete the resource using `DELETE /endpoint/{resource_id}`.
121222
122223
Raises:
@@ -125,11 +226,39 @@ def delete(self) -> None:
125226
Examples:
126227
contact.delete()
127228
"""
128-
response = self.do_action("DELETE")
229+
response = await self._do_action("DELETE")
129230
response.raise_for_status()
130231

131-
self.resource_ = None # noqa: WPS120
232+
self.resource_ = None
132233

133-
def _ensure_resource_is_fetched(self) -> None:
134-
if not self.resource_:
135-
self.fetch()
234+
async def _resource_action(
235+
self,
236+
method: str = "GET",
237+
url: str | None = None,
238+
json: ResourceData | ResourceList | None = None,
239+
) -> ResourceModel:
240+
"""Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`."""
241+
response = await self._do_action(method, url, json=json)
242+
self.resource_ = self._resource_class.from_response(response)
243+
return self.resource_
244+
245+
async def _do_action(
246+
self,
247+
method: str = "GET",
248+
url: str | None = None,
249+
json: ResourceData | ResourceList | None = None,
250+
) -> Response:
251+
"""Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`.
252+
253+
Args:
254+
method: The HTTP method to use.
255+
url: The action name to use.
256+
json: The updated resource data.
257+
258+
Raises:
259+
HTTPError: If the action fails.
260+
"""
261+
url = urljoin(self.resource_url, url) if url else self.resource_url
262+
response = await self.http_client_.request(method, url, json=json)
263+
response.raise_for_status()
264+
return response

mpt_api_client/models/base.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from mpt_api_client.models.meta import Meta
77

88
ResourceData = dict[str, Any]
9+
ResourceList = list[ResourceData]
910

1011

1112
class BaseResource(ABC):
@@ -28,7 +29,7 @@ def from_response(cls, response: Response) -> Self:
2829
raise NotImplementedError
2930

3031
@abstractmethod
31-
def to_dict(self) -> dict[str, Any]:
32+
def to_dict(self) -> ResourceData:
3233
"""Returns the resource as a dictionary."""
3334
raise NotImplementedError
3435

@@ -47,6 +48,6 @@ def from_response(cls, response: Response) -> Self:
4748
raise NotImplementedError
4849

4950
@abstractmethod
50-
def to_list(self) -> list[dict[str, Any]]:
51+
def to_list(self) -> ResourceList:
5152
"""Returns the collection as a list of dictionaries."""
5253
raise NotImplementedError

mpt_api_client/models/collection.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from collections.abc import Iterator
2-
from typing import Any, ClassVar, Self, override
2+
from typing import ClassVar, Self, override
33

44
from httpx import Response
55

6-
from mpt_api_client.models.base import BaseCollection, ResourceData
6+
from mpt_api_client.models.base import BaseCollection, ResourceList
77
from mpt_api_client.models.meta import Meta
88
from mpt_api_client.models.resource import Resource
99

@@ -15,7 +15,7 @@ class Collection[ResourceType](BaseCollection):
1515
_resource_model: type[Resource] = Resource
1616

1717
def __init__(
18-
self, collection_data: list[ResourceData] | None = None, meta: Meta | None = None
18+
self, collection_data: ResourceList | None = None, meta: Meta | None = None
1919
) -> None:
2020
self.meta = meta
2121
collection_data = collection_data or []
@@ -50,5 +50,5 @@ def from_response(cls, response: Response) -> Self:
5050
return cls(response_data, meta)
5151

5252
@override
53-
def to_list(self) -> list[dict[str, Any]]:
53+
def to_list(self) -> ResourceList:
5454
return [resource.to_dict() for resource in self._resource_collection]

0 commit comments

Comments
 (0)