Skip to content

Commit 0c44c49

Browse files
committed
MPT-12840 Add agreements assets (WIP)
1 parent 371d943 commit 0c44c49

File tree

14 files changed

+283
-93
lines changed

14 files changed

+283
-93
lines changed

mpt_api_client/http/async_service.py

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -91,19 +91,6 @@ async def get(self, resource_id: str, select: list[str] | str | None = None) ->
9191
select = ",".join(select) if select else None
9292
return await self._resource_action(resource_id=resource_id, query_params={"select": select})
9393

94-
async def update(self, resource_id: str, resource_data: ResourceData) -> Model:
95-
"""Update a resource using `PUT /endpoint/{resource_id}`.
96-
97-
Args:
98-
resource_id: Resource ID.
99-
resource_data: Resource data.
100-
101-
Returns:
102-
Resource object.
103-
104-
"""
105-
return await self._resource_action(resource_id, "PUT", json=resource_data)
106-
10794
async def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Response:
10895
"""Fetch one page of resources.
10996
@@ -119,7 +106,7 @@ async def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> ht
119106

120107
return response
121108

122-
async def _resource_do_request(
109+
async def _resource_do_request( # noqa: WPS211
123110
self,
124111
resource_id: str,
125112
method: str = "GET",
@@ -144,7 +131,7 @@ async def _resource_do_request(
144131
Raises:
145132
HTTPError: If the action fails.
146133
"""
147-
resource_url = urljoin(f"{self._endpoint}/", resource_id)
134+
resource_url = urljoin(f"{self.endpoint}/", resource_id)
148135
url = urljoin(f"{resource_url}/", action) if action else resource_url
149136
response = await self.http_client.request(
150137
method, url, json=json, params=query_params, headers=headers

mpt_api_client/http/mixins.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,23 @@ def delete(self, resource_id: str) -> None:
3131
response.raise_for_status()
3232

3333

34+
class UpdateMixin[Model]:
35+
"""Update resource mixin."""
36+
37+
def update(self, resource_id: str, resource_data: ResourceData) -> Model:
38+
"""Update a resource using `PUT /endpoint/{resource_id}`.
39+
40+
Args:
41+
resource_id: Resource ID.
42+
resource_data: Resource data.
43+
44+
Returns:
45+
Resource object.
46+
47+
"""
48+
return self._resource_action(resource_id, "PUT", json=resource_data) # type: ignore[attr-defined, no-any-return]
49+
50+
3451
class AsyncCreateMixin[Model]:
3552
"""Create resource mixin."""
3653

@@ -58,3 +75,20 @@ async def delete(self, resource_id: str) -> None:
5875
url = urljoin(f"{self.endpoint}/", resource_id) # type: ignore[attr-defined]
5976
response = await self.http_client.delete(url) # type: ignore[attr-defined]
6077
response.raise_for_status()
78+
79+
80+
class AsyncUpdateMixin[Model]:
81+
"""Update resource mixin."""
82+
83+
async def update(self, resource_id: str, resource_data: ResourceData) -> Model:
84+
"""Update a resource using `PUT /endpoint/{resource_id}`.
85+
86+
Args:
87+
resource_id: Resource ID.
88+
resource_data: Resource data.
89+
90+
Returns:
91+
Resource object.
92+
93+
"""
94+
return await self._resource_action(resource_id, "PUT", json=resource_data) # type: ignore[attr-defined, no-any-return]

mpt_api_client/http/service.py

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -91,19 +91,6 @@ def get(self, resource_id: str, select: list[str] | str | None = None) -> Model:
9191

9292
return self._resource_action(resource_id=resource_id, query_params={"select": select})
9393

94-
def update(self, resource_id: str, resource_data: ResourceData) -> Model:
95-
"""Update a resource using `PUT /endpoint/{resource_id}`.
96-
97-
Args:
98-
resource_id: Resource ID.
99-
resource_data: Resource data.
100-
101-
Returns:
102-
Resource object.
103-
104-
"""
105-
return self._resource_action(resource_id, "PUT", json=resource_data)
106-
10794
def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Response:
10895
"""Fetch one page of resources.
10996
@@ -119,7 +106,7 @@ def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Re
119106

120107
return response
121108

122-
def _resource_do_request(
109+
def _resource_do_request( # noqa: WPS211
123110
self,
124111
resource_id: str,
125112
method: str = "GET",

mpt_api_client/models/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from mpt_api_client.models.collection import Collection
2+
from mpt_api_client.models.download_file import DownloadFile
23
from mpt_api_client.models.meta import Meta, Pagination
34
from mpt_api_client.models.model import Model, ResourceData
4-
from mpt_api_client.models.file import File
55

6-
__all__ = ["Collection", "Meta", "Model", "Pagination", "ResourceData"] # noqa: WPS410
6+
__all__ = ["Collection", "DownloadFile", "Meta", "Model", "Pagination", "ResourceData"] # noqa: WPS410

mpt_api_client/models/file.py renamed to mpt_api_client/models/download_file.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from httpx import Response
44

55

6-
class File:
6+
class DownloadFile:
77
"""File resource."""
88

99
def __init__(self, response: Response):
@@ -32,7 +32,7 @@ def filename(self) -> str | None:
3232
return None
3333

3434
@property
35-
def content(self) -> bytes:
35+
def file_contents(self) -> bytes:
3636
"""Returns the content of the attachment.
3737
3838
Returns:
@@ -51,5 +51,5 @@ def content_type(self) -> str | None:
5151
Returns:
5252
The content type of the attachment.
5353
"""
54-
ctype = self.response.headers.get("content-type")
55-
return str(ctype) if ctype else None
54+
ctype = self.response.headers.get("content-type", "")
55+
return str(ctype)

mpt_api_client/resources/commerce/agreements.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from mpt_api_client.http import AsyncService, Service
2+
from mpt_api_client.http.mixins import AsyncUpdateMixin, UpdateMixin
23
from mpt_api_client.models import Model
34
from mpt_api_client.resources.commerce.agreements_assets import (
45
AgreementsAttachmentService,
@@ -18,7 +19,7 @@ class AgreementsServiceConfig:
1819
_collection_key = "data"
1920

2021

21-
class AgreementsService(Service[Agreement], AgreementsServiceConfig):
22+
class AgreementsService(UpdateMixin[Agreement], Service[Agreement], AgreementsServiceConfig):
2223
"""Agreements service."""
2324

2425
def template(self, agreement_id: str) -> str:
@@ -48,7 +49,9 @@ def attachments(self, agreement_id: str) -> AgreementsAttachmentService:
4849
)
4950

5051

51-
class AsyncAgreementsService(AsyncService[Agreement], AgreementsServiceConfig):
52+
class AsyncAgreementsService(
53+
AsyncUpdateMixin[Agreement], AsyncService[Agreement], AgreementsServiceConfig
54+
):
5255
"""Agreements service."""
5356

5457
async def template(self, agreement_id: str) -> str:

mpt_api_client/resources/commerce/agreements_assets.py

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
from typing import AsyncIterator, Iterator, Optional
2-
import re
1+
import json
32

43
from httpx import Response
5-
from httpx._types import RequestFiles
4+
from httpx._types import FileTypes
65

76
from mpt_api_client.http import AsyncService, Service
8-
from mpt_api_client.models import File, Model, ResourceData
7+
from mpt_api_client.models import DownloadFile, Model, ResourceData
98

109

1110
class AgreementAttachment(Model):
@@ -24,13 +23,45 @@ class AgreementsAttachmentService(Service[AgreementAttachment], AgreementsAttach
2423
"""Attachments service."""
2524

2625
def create(
27-
self, resource_data: ResourceData, files: RequestFiles | None = None
26+
self, resource_data: ResourceData, files: dict[str, FileTypes] | None = None
2827
) -> AgreementAttachment:
29-
response = self.http_client.post(self.endpoint, json=resource_data, files=files)
28+
"""Create AgreementAttachment resource.
29+
30+
Args:
31+
resource_data: Resource data.
32+
files: Files data.
33+
34+
Returns:
35+
AgreementAttachment resource.
36+
"""
37+
files = files or {}
38+
39+
# Note: This is a workaround to fulfill MPT API request format
40+
#
41+
# HTTPx does not support sending json and files in the same call
42+
# currently only supports sending form-data and files in the same call.
43+
# https://www.python-httpx.org/quickstart/#sending-multipart-file-uploads
44+
#
45+
# MPT API expects files and data to be submitted in a multipart form-data upload.
46+
# https://softwareone.atlassian.net/wiki/spaces/mpt/pages/5212079859/Commerce+API#Create-Agreement-Attachment
47+
#
48+
# Current workaround is to send the json data as an unnamed file.
49+
# This ends adding the json as payload multipart data.
50+
#
51+
# json.dumps is setup using the same params of httpx json encoder to produce the same
52+
# encodings.
53+
54+
if resource_data:
55+
json_payload = json.dumps(
56+
resource_data, ensure_ascii=False, separators=(",", ":"), allow_nan=False
57+
).encode("utf-8")
58+
files["_attachment_data"] = (None, json_payload, "application/json")
59+
60+
response = self.http_client.post(self.endpoint, files=files)
3061
response.raise_for_status()
3162
return AgreementAttachment.from_response(response)
3263

33-
def download(self, agreement_id: str) -> File:
64+
def download(self, agreement_id: str) -> DownloadFile:
3465
"""Renders the template for the given Agreement id.
3566
3667
Args:
@@ -40,18 +71,17 @@ def download(self, agreement_id: str) -> File:
4071
Agreement template.
4172
"""
4273
response: Response = self._resource_do_request(
43-
agreement_id,
44-
method="GET",
74+
agreement_id, method="GET", headers={"Accept": "*"}
4575
)
46-
return File(response)
76+
return DownloadFile(response)
4777

4878

4979
class AsyncAgreementsAttachmentService(
5080
AsyncService[AgreementAttachment], AgreementsAttachmentServiceConfig
5181
):
5282
"""Attachments service."""
5383

54-
async def download(self, agreement_id: str) -> File:
84+
async def download(self, agreement_id: str) -> DownloadFile:
5585
"""Renders the template for the given Agreement id.
5686
5787
Args:
@@ -63,4 +93,4 @@ async def download(self, agreement_id: str) -> File:
6393
response = await self._resource_do_request(
6494
agreement_id, method="GET", headers={"Accept": "*"}
6595
)
66-
return File(response)
96+
return DownloadFile(response)

mpt_api_client/resources/commerce/orders.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
DeleteMixin,
77
Service,
88
)
9+
from mpt_api_client.http.mixins import AsyncUpdateMixin, UpdateMixin
910
from mpt_api_client.models import Model, ResourceData
1011

1112

@@ -24,6 +25,7 @@ class OrdersServiceConfig:
2425
class OrdersService( # noqa: WPS215
2526
CreateMixin[Order],
2627
DeleteMixin,
28+
UpdateMixin[Order],
2729
Service[Order],
2830
OrdersServiceConfig,
2931
):
@@ -99,6 +101,7 @@ def template(self, resource_id: str) -> str:
99101
class AsyncOrdersService( # noqa: WPS215
100102
AsyncCreateMixin[Order],
101103
AsyncDeleteMixin,
104+
AsyncUpdateMixin[Order],
102105
AsyncService[Order],
103106
OrdersServiceConfig,
104107
):

tests/http/conftest.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,26 @@
1010
DeleteMixin,
1111
Service,
1212
)
13+
from mpt_api_client.http.mixins import AsyncUpdateMixin, UpdateMixin
1314
from tests.conftest import DummyModel
1415

1516

16-
class DummyService(CreateMixin[DummyModel], DeleteMixin, Service[DummyModel]):
17+
class DummyService( # noqa: WPS215
18+
CreateMixin[DummyModel],
19+
DeleteMixin,
20+
UpdateMixin[DummyModel],
21+
Service[DummyModel],
22+
):
1723
_endpoint = "/api/v1/test"
1824
_model_class = DummyModel
1925

2026

21-
class AsyncDummyService(AsyncCreateMixin[DummyModel], AsyncDeleteMixin, AsyncService[DummyModel]):
27+
class AsyncDummyService( # noqa: WPS215
28+
AsyncCreateMixin[DummyModel],
29+
AsyncDeleteMixin,
30+
AsyncUpdateMixin[DummyModel],
31+
AsyncService[DummyModel],
32+
):
2233
_endpoint = "/api/v1/test"
2334
_model_class = DummyModel
2435

tests/http/test_async_service.py

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import json
2-
31
import httpx
42
import pytest
53
import respx
@@ -244,22 +242,6 @@ async def test_async_iterate_lazy_evaluation(async_dummy_service):
244242
assert mock_route.call_count == 1
245243

246244

247-
async def test_async_update_resource(async_dummy_service): # noqa: WPS210
248-
resource_data = {"name": "Test Resource", "status": "active"}
249-
update_response = httpx.Response(httpx.codes.OK, json=resource_data)
250-
251-
with respx.mock:
252-
mock_route = respx.put("https://api.example.com/api/v1/test/RES-123").mock(
253-
return_value=update_response
254-
)
255-
256-
await async_dummy_service.update("RES-123", resource_data)
257-
258-
request = mock_route.calls[0].request
259-
assert mock_route.call_count == 1
260-
assert json.loads(request.content.decode()) == resource_data
261-
262-
263245
async def test_async_get(async_dummy_service):
264246
resource_data = {"id": "RES-123", "name": "Test Resource"}
265247
with respx.mock:

0 commit comments

Comments
 (0)