Skip to content

Commit 0d09d83

Browse files
authored
MPT-16437 Update http/mixins file structure (#200)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> Closes [MPT-16437](https://softwareone.atlassian.net/browse/MPT-16437) - Restructures HTTP mixins into a package: replaces monolithic mpt_api_client/http/mixins.py with mpt_api_client/http/mixins/ and individual modules (collection_mixin, create_mixin, delete_mixin, get_mixin, update_mixin, enable_mixin, disable_mixin, create_file_mixin, update_file_mixin, download_file_mixin, file_operations_mixin, queryable_mixin, resource_mixins). - Adds __init__.py to re-export the full public mixin API surface (sync and async variants) to preserve compatibility. - Implements pagination, queryable, file upload/download, CRUD, enable/disable, and composite resource mixins as separate modules. - Moves and expands unit tests from a single tests/unit/http/test_mixins.py into focused tests under tests/unit/http/mixins/* covering each mixin and async/sync behaviors. - Updates pyproject.toml linting ignores to accommodate the new directory layout. <!-- end of auto-generated comment: release notes by coderabbit.ai --> [MPT-16437]: https://softwareone.atlassian.net/browse/MPT-16437?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
2 parents 8398818 + 92149de commit 0d09d83

31 files changed

+2344
-2102
lines changed

mpt_api_client/http/mixins.py

Lines changed: 0 additions & 665 deletions
This file was deleted.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from mpt_api_client.http.mixins.collection_mixin import (
2+
AsyncCollectionMixin,
3+
CollectionMixin,
4+
)
5+
from mpt_api_client.http.mixins.create_file_mixin import (
6+
AsyncCreateFileMixin,
7+
CreateFileMixin,
8+
)
9+
from mpt_api_client.http.mixins.create_mixin import AsyncCreateMixin, CreateMixin
10+
from mpt_api_client.http.mixins.delete_mixin import AsyncDeleteMixin, DeleteMixin
11+
from mpt_api_client.http.mixins.disable_mixin import AsyncDisableMixin, DisableMixin
12+
from mpt_api_client.http.mixins.download_file_mixin import (
13+
AsyncDownloadFileMixin,
14+
DownloadFileMixin,
15+
)
16+
from mpt_api_client.http.mixins.enable_mixin import AsyncEnableMixin, EnableMixin
17+
from mpt_api_client.http.mixins.file_operations_mixin import (
18+
AsyncFilesOperationsMixin,
19+
FilesOperationsMixin,
20+
)
21+
from mpt_api_client.http.mixins.get_mixin import AsyncGetMixin, GetMixin
22+
from mpt_api_client.http.mixins.queryable_mixin import QueryableMixin
23+
from mpt_api_client.http.mixins.resource_mixins import (
24+
AsyncManagedResourceMixin,
25+
AsyncModifiableResourceMixin,
26+
ManagedResourceMixin,
27+
ModifiableResourceMixin,
28+
)
29+
from mpt_api_client.http.mixins.update_file_mixin import (
30+
AsyncUpdateFileMixin,
31+
UpdateFileMixin,
32+
)
33+
from mpt_api_client.http.mixins.update_mixin import AsyncUpdateMixin, UpdateMixin
34+
35+
__all__ = [ # noqa: WPS410
36+
"AsyncCollectionMixin",
37+
"AsyncCreateFileMixin",
38+
"AsyncCreateMixin",
39+
"AsyncDeleteMixin",
40+
"AsyncDisableMixin",
41+
"AsyncDownloadFileMixin",
42+
"AsyncEnableMixin",
43+
"AsyncFilesOperationsMixin",
44+
"AsyncGetMixin",
45+
"AsyncManagedResourceMixin",
46+
"AsyncModifiableResourceMixin",
47+
"AsyncUpdateFileMixin",
48+
"AsyncUpdateMixin",
49+
"CollectionMixin",
50+
"CreateFileMixin",
51+
"CreateMixin",
52+
"DeleteMixin",
53+
"DisableMixin",
54+
"DownloadFileMixin",
55+
"EnableMixin",
56+
"FilesOperationsMixin",
57+
"GetMixin",
58+
"ManagedResourceMixin",
59+
"ModifiableResourceMixin",
60+
"QueryableMixin",
61+
"UpdateFileMixin",
62+
"UpdateMixin",
63+
]
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
from collections.abc import AsyncIterator, Iterator
2+
3+
from mpt_api_client.http.mixins.queryable_mixin import QueryableMixin
4+
from mpt_api_client.http.types import Response
5+
from mpt_api_client.models import Collection
6+
from mpt_api_client.models import Model as BaseModel
7+
8+
9+
class CollectionMixin[Model: BaseModel](QueryableMixin):
10+
"""Mixin providing collection functionality."""
11+
12+
def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[Model]:
13+
"""Fetch one page of resources.
14+
15+
Returns:
16+
Collection of resources.
17+
"""
18+
response = self._fetch_page_as_response(limit=limit, offset=offset)
19+
return self.make_collection(response) # type: ignore[attr-defined, no-any-return]
20+
21+
def fetch_one(self) -> Model:
22+
"""Fetch one resource, expect exactly one result.
23+
24+
Returns:
25+
One resource.
26+
27+
Raises:
28+
ValueError: If the total matching records are not exactly one.
29+
"""
30+
response = self._fetch_page_as_response(limit=1, offset=0)
31+
resource_list = self.make_collection(response) # type: ignore[attr-defined]
32+
total_records = len(resource_list)
33+
if resource_list.meta:
34+
total_records = resource_list.meta.pagination.total
35+
if total_records == 0:
36+
raise ValueError("Expected one result, but got zero results")
37+
if total_records > 1:
38+
raise ValueError(f"Expected one result, but got {total_records} results")
39+
40+
return resource_list[0] # type: ignore[no-any-return]
41+
42+
def iterate(self, batch_size: int = 100) -> Iterator[Model]:
43+
"""Iterate over all resources, yielding GenericResource objects.
44+
45+
Args:
46+
batch_size: Number of resources to fetch per request
47+
48+
Returns:
49+
Iterator of resources.
50+
"""
51+
offset = 0
52+
limit = batch_size # Default page size
53+
54+
while True:
55+
response = self._fetch_page_as_response(limit=limit, offset=offset)
56+
items_collection = self.make_collection(response) # type: ignore[attr-defined]
57+
yield from items_collection
58+
59+
if not items_collection.meta:
60+
break
61+
if not items_collection.meta.pagination.has_next():
62+
break
63+
offset = items_collection.meta.pagination.next_offset()
64+
65+
def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> Response:
66+
"""Fetch one page of resources.
67+
68+
Returns:
69+
Response object.
70+
71+
Raises:
72+
HTTPStatusError: if the response status code is not 200.
73+
"""
74+
pagination_params: dict[str, int] = {"limit": limit, "offset": offset}
75+
return self.http_client.request("get", self.build_path(pagination_params)) # type: ignore[attr-defined, no-any-return]
76+
77+
78+
class AsyncCollectionMixin[Model: BaseModel](QueryableMixin):
79+
"""Async mixin providing collection functionality."""
80+
81+
async def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[Model]:
82+
"""Fetch one page of resources.
83+
84+
Returns:
85+
Collection of resources.
86+
"""
87+
response = await self._fetch_page_as_response(limit=limit, offset=offset)
88+
return self.make_collection(response) # type: ignore[no-any-return,attr-defined]
89+
90+
async def fetch_one(self) -> Model:
91+
"""Fetch one resource, expect exactly one result.
92+
93+
Returns:
94+
One resource.
95+
96+
Raises:
97+
ValueError: If the total matching records are not exactly one.
98+
"""
99+
response = await self._fetch_page_as_response(limit=1, offset=0)
100+
resource_list = self.make_collection(response) # type: ignore[attr-defined]
101+
total_records = len(resource_list)
102+
if resource_list.meta:
103+
total_records = resource_list.meta.pagination.total
104+
if total_records == 0:
105+
raise ValueError("Expected one result, but got zero results")
106+
if total_records > 1:
107+
raise ValueError(f"Expected one result, but got {total_records} results")
108+
109+
return resource_list[0] # type: ignore[no-any-return]
110+
111+
async def iterate(self, batch_size: int = 100) -> AsyncIterator[Model]:
112+
"""Iterate over all resources, yielding GenericResource objects.
113+
114+
Args:
115+
batch_size: Number of resources to fetch per request
116+
117+
Returns:
118+
Iterator of resources.
119+
"""
120+
offset = 0
121+
limit = batch_size # Default page size
122+
123+
while True:
124+
response = await self._fetch_page_as_response(limit=limit, offset=offset)
125+
items_collection = self.make_collection(response) # type: ignore[attr-defined]
126+
for resource in items_collection:
127+
yield resource
128+
129+
if not items_collection.meta:
130+
break
131+
if not items_collection.meta.pagination.has_next():
132+
break
133+
offset = items_collection.meta.pagination.next_offset()
134+
135+
async def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> Response:
136+
"""Fetch one page of resources.
137+
138+
Returns:
139+
Response object.
140+
141+
Raises:
142+
HTTPStatusError: if the response status code is not 200.
143+
"""
144+
pagination_params: dict[str, int] = {"limit": limit, "offset": offset}
145+
return await self.http_client.request("get", self.build_path(pagination_params)) # type: ignore[attr-defined,no-any-return]
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from mpt_api_client.http.types import FileTypes
2+
from mpt_api_client.models import ResourceData
3+
4+
5+
class CreateFileMixin[Model]:
6+
"""Create file mixin."""
7+
8+
def create(self, resource_data: ResourceData, file: FileTypes | None = None) -> Model: # noqa: WPS110
9+
"""Create logo.
10+
11+
Create a file resource by specifying a file image.
12+
13+
Args:
14+
resource_data: Resource data.
15+
file: File image.
16+
17+
Returns:
18+
Model: Created resource.
19+
"""
20+
files = {}
21+
22+
if file:
23+
files[self._upload_file_key] = file # type: ignore[attr-defined]
24+
25+
response = self.http_client.request( # type: ignore[attr-defined]
26+
"post",
27+
self.path, # type: ignore[attr-defined]
28+
json=resource_data,
29+
files=files,
30+
json_file_key=self._upload_data_key, # type: ignore[attr-defined]
31+
force_multipart=True,
32+
)
33+
34+
return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return]
35+
36+
37+
class AsyncCreateFileMixin[Model]:
38+
"""Asynchronous Create file mixin."""
39+
40+
async def create(self, resource_data: ResourceData, file: FileTypes | None = None) -> Model: # noqa: WPS110
41+
"""Create file.
42+
43+
Create a file resource by specifying a file.
44+
45+
Args:
46+
resource_data: Resource data.
47+
file: File image.
48+
49+
Returns:
50+
Model: Created resource.
51+
"""
52+
files = {}
53+
54+
if file:
55+
files[self._upload_file_key] = file # type: ignore[attr-defined]
56+
57+
response = await self.http_client.request( # type: ignore[attr-defined]
58+
"post",
59+
self.path, # type: ignore[attr-defined]
60+
json=resource_data,
61+
files=files,
62+
json_file_key=self._upload_data_key, # type: ignore[attr-defined]
63+
force_multipart=True,
64+
)
65+
66+
return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return]
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from mpt_api_client.models import ResourceData
2+
3+
4+
class CreateMixin[Model]:
5+
"""Create resource mixin."""
6+
7+
def create(self, resource_data: ResourceData) -> Model:
8+
"""Create a new resource using `POST /endpoint`.
9+
10+
Returns:
11+
New resource created.
12+
"""
13+
response = self.http_client.request("post", self.path, json=resource_data) # type: ignore[attr-defined]
14+
15+
return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return]
16+
17+
18+
class AsyncCreateMixin[Model]:
19+
"""Create resource mixin."""
20+
21+
async def create(self, resource_data: ResourceData) -> Model:
22+
"""Create a new resource using `POST /endpoint`.
23+
24+
Returns:
25+
New resource created.
26+
"""
27+
response = await self.http_client.request("post", self.path, json=resource_data) # type: ignore[attr-defined]
28+
29+
return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return]
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from urllib.parse import urljoin
2+
3+
4+
class DeleteMixin:
5+
"""Delete resource mixin."""
6+
7+
def delete(self, resource_id: str) -> None:
8+
"""Delete resource using `DELETE /endpoint/{resource_id}`.
9+
10+
Args:
11+
resource_id: Resource ID.
12+
"""
13+
self._resource_do_request(resource_id, "DELETE") # type: ignore[attr-defined]
14+
15+
16+
class AsyncDeleteMixin:
17+
"""Delete resource mixin."""
18+
19+
async def delete(self, resource_id: str) -> None:
20+
"""Delete resource using `DELETE /endpoint/{resource_id}`.
21+
22+
Args:
23+
resource_id: Resource ID.
24+
"""
25+
url = urljoin(f"{self.path}/", resource_id) # type: ignore[attr-defined]
26+
await self.http_client.request("delete", url) # type: ignore[attr-defined]
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from mpt_api_client.models import Model as BaseModel
2+
from mpt_api_client.models import ResourceData
3+
4+
5+
class AsyncDisableMixin[Model: BaseModel]:
6+
"""Disable resource mixin."""
7+
8+
async def disable(self, resource_id: str, resource_data: ResourceData | None = None) -> Model:
9+
"""Disable a specific resource."""
10+
return await self._resource_action( # type: ignore[attr-defined, no-any-return]
11+
resource_id=resource_id, method="POST", action="disable", json=resource_data
12+
)
13+
14+
15+
class DisableMixin[Model: BaseModel]:
16+
"""Disable resource mixin."""
17+
18+
def disable(self, resource_id: str, resource_data: ResourceData | None = None) -> Model:
19+
"""Disable a specific resource."""
20+
return self._resource_action( # type: ignore[attr-defined, no-any-return]
21+
resource_id=resource_id, method="POST", action="disable", json=resource_data
22+
)

0 commit comments

Comments
 (0)