Skip to content

Commit a77013c

Browse files
committed
MPT-12358 Resource client async
1 parent 9f1c9c7 commit a77013c

File tree

9 files changed

+407
-33
lines changed

9 files changed

+407
-33
lines changed

mpt_api_client/http/collection.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
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
1111
from mpt_api_client.rql.query_builder import RQLQuery
1212

@@ -230,7 +230,7 @@ def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Re
230230

231231
class AsyncCollectionClientBase[
232232
ResourceModel: Resource,
233-
ResourceClient: ResourceBaseClient[Resource],
233+
ResourceClient: AsyncResourceBaseClient[Resource],
234234
](ABC, CollectionMixin):
235235
"""Immutable Base client for RESTful resource collections.
236236
@@ -313,7 +313,7 @@ async def iterate(self, batch_size: int = 100) -> AsyncIterator[ResourceModel]:
313313

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

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

mpt_api_client/http/resource.py

Lines changed: 146 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,27 @@
33

44
from httpx import Response
55

6-
from mpt_api_client.http.client import HTTPClient
6+
from mpt_api_client.http.client import HTTPClient, HTTPClientAsync
77
from mpt_api_client.models import Resource
88

99

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

1313
_endpoint: str
14-
_resource_class: type[ResourceModel]
14+
_resource_class: type[Any]
1515
_safe_attributes: ClassVar[set[str]] = {"http_client_", "resource_id_", "resource_"}
1616

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
17+
def __init__(
18+
self, http_client: HTTPClient | HTTPClientAsync, resource_id: str, resource: Resource | None
19+
) -> None:
20+
self.http_client_ = http_client
21+
self.resource_id_ = resource_id
22+
self.resource_: Resource | None = resource
2123

2224
def __getattr__(self, attribute: str) -> Any:
2325
"""Returns the resource data."""
24-
self._ensure_resource_is_fetched()
26+
self._assert_resource_is_set()
2527
return self.resource_.__getattr__(attribute) # type: ignore[union-attr]
2628

2729
@property
@@ -34,9 +36,32 @@ def __setattr__(self, attribute: str, attribute_value: Any) -> None:
3436
if attribute in self._safe_attributes:
3537
object.__setattr__(self, attribute, attribute_value)
3638
return
37-
self._ensure_resource_is_fetched()
39+
self._assert_resource_is_set()
3840
self.resource_.__setattr__(attribute, attribute_value)
3941

42+
def _assert_resource_is_set(self) -> None:
43+
if not self.resource_:
44+
raise RuntimeError(
45+
f"Resource data not available. Call fetch() method first to retrieve" # noqa: WPS237
46+
f" the resource `{self._resource_class.__name__}`"
47+
)
48+
49+
50+
class ResourceBaseClient[ResourceModel: Resource](ABC, ResourceMixin): # noqa: WPS214
51+
"""Client for RESTful resources."""
52+
53+
_endpoint: str
54+
_resource_class: type[ResourceModel]
55+
_safe_attributes: ClassVar[set[str]] = {"http_client_", "resource_id_", "resource_"}
56+
57+
def __init__(
58+
self, http_client: HTTPClient, resource_id: str, resource: Resource | None = None
59+
) -> None:
60+
self.http_client_: HTTPClient = http_client # type: ignore[mutable-override]
61+
ResourceMixin.__init__(
62+
self, http_client=http_client, resource_id=resource_id, resource=resource
63+
)
64+
4065
def fetch(self) -> ResourceModel:
4166
"""Fetch a specific resource using `GET /endpoint/{resource_id}`.
4267
@@ -47,7 +72,7 @@ def fetch(self) -> ResourceModel:
4772
"""
4873
response = self.do_action("GET")
4974

50-
self.resource_ = self._resource_class.from_response(response) # noqa: WPS120
75+
self.resource_ = self._resource_class.from_response(response)
5176
return self.resource_
5277

5378
def resource_action(
@@ -58,7 +83,7 @@ def resource_action(
5883
) -> ResourceModel:
5984
"""Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`."""
6085
response = self.do_action(method, url, json=json)
61-
self.resource_ = self._resource_class.from_response(response) # noqa: WPS120
86+
self.resource_ = self._resource_class.from_response(response)
6287
return self.resource_
6388

6489
def do_action(
@@ -97,7 +122,7 @@ def update(self, resource_data: dict[str, Any]) -> ResourceModel:
97122
98123
"""
99124
response = self.do_action("PUT", json=resource_data)
100-
self.resource_ = self._resource_class.from_response(response) # noqa: WPS120
125+
self.resource_ = self._resource_class.from_response(response)
101126
return self.resource_
102127

103128
def save(self) -> Self:
@@ -111,9 +136,8 @@ def save(self) -> Self:
111136
contact.save()
112137
113138
"""
114-
if not self.resource_:
115-
raise ValueError("Unable to save resource that has not been set.")
116-
self.update(self.resource_.to_dict())
139+
self._assert_resource_is_set()
140+
self.update(self.resource_.to_dict()) # type: ignore[union-attr]
117141
return self
118142

119143
def delete(self) -> None:
@@ -128,8 +152,110 @@ def delete(self) -> None:
128152
response = self.do_action("DELETE")
129153
response.raise_for_status()
130154

131-
self.resource_ = None # noqa: WPS120
155+
self.resource_ = None
132156

133-
def _ensure_resource_is_fetched(self) -> None:
134-
if not self.resource_:
135-
self.fetch()
157+
158+
class AsyncResourceBaseClient[ResourceModel: Resource](ABC, ResourceMixin): # noqa: WPS214
159+
"""Client for RESTful resources."""
160+
161+
_endpoint: str
162+
_resource_class: type[ResourceModel]
163+
_safe_attributes: ClassVar[set[str]] = {"http_client_", "resource_id_", "resource_"}
164+
165+
def __init__(
166+
self, http_client: HTTPClientAsync, resource_id: str, resource: Resource | None = None
167+
) -> None:
168+
self.http_client_: HTTPClientAsync = http_client # type: ignore[mutable-override]
169+
ResourceMixin.__init__(
170+
self, http_client=http_client, resource_id=resource_id, resource=resource
171+
)
172+
173+
async def fetch(self) -> ResourceModel:
174+
"""Fetch a specific resource using `GET /endpoint/{resource_id}`.
175+
176+
It fetches and caches the resource.
177+
178+
Returns:
179+
The fetched resource.
180+
"""
181+
response = await self.do_action("GET")
182+
183+
self.resource_ = self._resource_class.from_response(response)
184+
return self.resource_
185+
186+
async def resource_action(
187+
self,
188+
method: str = "GET",
189+
url: str | None = None,
190+
json: dict[str, Any] | list[Any] | None = None, # noqa: WPS221
191+
) -> ResourceModel:
192+
"""Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`."""
193+
response = await self.do_action(method, url, json=json)
194+
self.resource_ = self._resource_class.from_response(response)
195+
return self.resource_
196+
197+
async def do_action(
198+
self,
199+
method: str = "GET",
200+
url: str | None = None,
201+
json: dict[str, Any] | list[Any] | None = None, # noqa: WPS221
202+
) -> Response:
203+
"""Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`.
204+
205+
Args:
206+
method: The HTTP method to use.
207+
url: The action name to use.
208+
json: The updated resource data.
209+
210+
Raises:
211+
HTTPError: If the action fails.
212+
"""
213+
url = f"{self.resource_url}/{url}" if url else self.resource_url
214+
response = await self.http_client_.request(method, url, json=json)
215+
response.raise_for_status()
216+
return response
217+
218+
async def update(self, resource_data: dict[str, Any]) -> ResourceModel:
219+
"""Update a specific in the API and catches the result as a current resource.
220+
221+
Args:
222+
resource_data: The updated resource data.
223+
224+
Returns:
225+
The updated resource.
226+
227+
Examples:
228+
updated_contact = contact.update({"name": "New Name"})
229+
230+
231+
"""
232+
return await self.resource_action("PUT", json=resource_data)
233+
234+
async def save(self) -> Self:
235+
"""Save the current state of the resource to the api using the update method.
236+
237+
Raises:
238+
ValueError: If the resource has not been set.
239+
240+
Examples:
241+
contact.name = "New Name"
242+
contact.save()
243+
244+
"""
245+
self._assert_resource_is_set()
246+
await self.update(self.resource_.to_dict()) # type: ignore[union-attr]
247+
return self
248+
249+
async def delete(self) -> None:
250+
"""Delete the resource using `DELETE /endpoint/{resource_id}`.
251+
252+
Raises:
253+
HTTPStatusError: If the deletion fails.
254+
255+
Examples:
256+
contact.delete()
257+
"""
258+
response = await self.do_action("DELETE")
259+
response.raise_for_status()
260+
261+
self.resource_ = None

setup.cfg

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,25 @@ extend-ignore =
3333

3434
per-file-ignores =
3535
mpt_api_client/rql/query_builder.py:
36-
# Forbid blacklisted variable names
36+
# Forbid blacklisted variable names.
3737
WPS110
38-
# Found `noqa` comments overuse
38+
# Found `noqa` comments overuse.
3939
WPS402
4040
tests/http/collection/test_collection_client_iterate.py:
4141
# Found too many module members
4242
WPS202
4343
tests/http/collection/test_collection_client_fetch.py:
44-
# Found too many module members
44+
# Found too many module members.
4545
WPS202
46-
# Found magic number
46+
# Found magic number.
4747
WPS432
48+
mpt_api_client/http/resource.py:
49+
WPS120
50+
# Found regular name with trailing underscore.
51+
# We use trailing underscore to avoid potential collisions with API models attributes.
4852
tests/*:
49-
# Allow magic strings
53+
# Allow magic strings.
5054
WPS432
51-
# Found too many modules members
55+
# Found too many modules members.
5256
WPS202
5357

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import pytest
2+
3+
4+
@pytest.mark.asyncio
5+
async def test_get(async_collection_client):
6+
resource = await async_collection_client.get("RES-123")
7+
assert resource.resource_id_ == "RES-123"
8+
assert isinstance(resource, async_collection_client._resource_client_class) # noqa: SLF001

tests/http/conftest.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from mpt_api_client.http.client import HTTPClient, HTTPClientAsync
44
from mpt_api_client.http.collection import AsyncCollectionClientBase, CollectionClientBase
5-
from mpt_api_client.http.resource import ResourceBaseClient
5+
from mpt_api_client.http.resource import AsyncResourceBaseClient, ResourceBaseClient
66
from mpt_api_client.models import Collection
77
from tests.conftest import DummyResource
88

@@ -19,10 +19,17 @@ class DummyCollectionClientBase(CollectionClientBase[DummyResource, DummyResourc
1919
_collection_class = Collection[DummyResource]
2020

2121

22-
class DummyAsyncCollectionClientBase(AsyncCollectionClientBase[DummyResource, DummyResourceClient]):
22+
class DummyAsyncResourceClient(AsyncResourceBaseClient[DummyResource]):
23+
_endpoint = "/api/v1/test-resource"
24+
_resource_class = DummyResource
25+
26+
27+
class DummyAsyncCollectionClientBase(
28+
AsyncCollectionClientBase[DummyResource, DummyAsyncResourceClient]
29+
):
2330
_endpoint = "/api/v1/test"
2431
_resource_class = DummyResource
25-
_resource_client_class = DummyResourceClient
32+
_resource_client_class = DummyAsyncResourceClient
2633
_collection_class = Collection[DummyResource]
2734

2835

@@ -59,3 +66,9 @@ def collection_client(http_client) -> DummyCollectionClientBase:
5966
@pytest.fixture
6067
def async_collection_client(http_client_async) -> DummyAsyncCollectionClientBase:
6168
return DummyAsyncCollectionClientBase(http_client=http_client_async)
69+
70+
71+
@pytest.fixture
72+
def async_resource_client(http_client_async):
73+
"""Create an async resource client for testing."""
74+
return DummyAsyncResourceClient(http_client=http_client_async, resource_id="RES-123")

0 commit comments

Comments
 (0)