diff --git a/mpt_api_client/http/base_service.py b/mpt_api_client/http/base_service.py index b1d55563..21b757be 100644 --- a/mpt_api_client/http/base_service.py +++ b/mpt_api_client/http/base_service.py @@ -2,7 +2,7 @@ from mpt_api_client.http.query_state import QueryState from mpt_api_client.http.types import Response -from mpt_api_client.models import Collection, Meta +from mpt_api_client.models import Meta, ModelCollection from mpt_api_client.models import Model as BaseModel @@ -42,16 +42,16 @@ def build_path( return f"{self.path}?{query}" if query else self.path @classmethod - def make_collection(cls, response: Response) -> Collection[Model]: + def make_collection(cls, response: Response) -> ModelCollection[Model]: """Builds a collection from a response. Args: response: The response object. """ meta = Meta.from_response(response) - return Collection( + return ModelCollection( resources=[ - cls._model_class.new(resource, meta) + cls._model_class(resource, meta) for resource in response.json().get(cls._collection_key) ], meta=meta, diff --git a/mpt_api_client/http/mixins/collection_mixin.py b/mpt_api_client/http/mixins/collection_mixin.py index 8f28fe82..3196ed26 100644 --- a/mpt_api_client/http/mixins/collection_mixin.py +++ b/mpt_api_client/http/mixins/collection_mixin.py @@ -2,14 +2,14 @@ from mpt_api_client.http.mixins.queryable_mixin import QueryableMixin from mpt_api_client.http.types import Response -from mpt_api_client.models import Collection from mpt_api_client.models import Model as BaseModel +from mpt_api_client.models import ModelCollection class CollectionMixin[Model: BaseModel](QueryableMixin): """Mixin providing collection functionality.""" - def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[Model]: + def fetch_page(self, limit: int = 100, offset: int = 0) -> ModelCollection[Model]: """Fetch one page of resources. Returns: @@ -78,7 +78,7 @@ def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> Response class AsyncCollectionMixin[Model: BaseModel](QueryableMixin): """Async mixin providing collection functionality.""" - async def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[Model]: + async def fetch_page(self, limit: int = 100, offset: int = 0) -> ModelCollection[Model]: """Fetch one page of resources. Returns: diff --git a/mpt_api_client/http/resource_accessor.py b/mpt_api_client/http/resource_accessor.py index a7247645..9f267700 100644 --- a/mpt_api_client/http/resource_accessor.py +++ b/mpt_api_client/http/resource_accessor.py @@ -4,8 +4,8 @@ from mpt_api_client.http.query_options import QueryOptions from mpt_api_client.http.types import QueryParam, Response from mpt_api_client.http.url_utils import join_url_path -from mpt_api_client.models.collection import ResourceList from mpt_api_client.models.model import Model, ResourceData # NOSONAR +from mpt_api_client.models.model_collection import ModelCollection, ResourceList _JsonPayload = ResourceData | ResourceList | None @@ -65,7 +65,7 @@ def get( options: QueryOptions | None = None, ) -> ResourceModel: """``GET`` the resource (optionally with a sub-action).""" - return self._action("GET", action, query_params=query_params, options=options) + return self._action("GET", action, query_params=query_params, options=options) # type: ignore[return-value] def post( self, @@ -75,7 +75,7 @@ def post( query_params: QueryParam | None = None, ) -> ResourceModel: """``POST`` to the resource (optionally with a sub-action).""" - return self._action("POST", action, json=json, query_params=query_params) + return self._action("POST", action, json=json, query_params=query_params) # type: ignore[return-value] def put( self, @@ -85,7 +85,7 @@ def put( query_params: QueryParam | None = None, ) -> ResourceModel: """``PUT`` to the resource (optionally with a sub-action).""" - return self._action("PUT", action, json=json, query_params=query_params) + return self._action("PUT", action, json=json, query_params=query_params) # type: ignore[return-value] def delete(self) -> None: """``DELETE`` the resource.""" @@ -99,7 +99,7 @@ def _action( json: _JsonPayload = None, query_params: QueryParam | None = None, options: QueryOptions | None = None, - ) -> ResourceModel: + ) -> ResourceModel | ModelCollection[ResourceModel]: response = self.do_request( method, action, @@ -164,7 +164,7 @@ async def get( options: QueryOptions | None = None, ) -> ResourceModel: """``GET`` the resource (optionally with a sub-action).""" - return await self._action("GET", action, query_params=query_params, options=options) + return await self._action("GET", action, query_params=query_params, options=options) # type: ignore[return-value] async def post( self, @@ -174,7 +174,7 @@ async def post( query_params: QueryParam | None = None, ) -> ResourceModel: """``POST`` to the resource (optionally with a sub-action).""" - return await self._action("POST", action, json=json, query_params=query_params) + return await self._action("POST", action, json=json, query_params=query_params) # type: ignore[return-value] async def put( self, @@ -184,7 +184,7 @@ async def put( query_params: QueryParam | None = None, ) -> ResourceModel: """``PUT`` to the resource (optionally with a sub-action).""" - return await self._action("PUT", action, json=json, query_params=query_params) + return await self._action("PUT", action, json=json, query_params=query_params) # type: ignore[return-value] async def delete(self) -> None: """``DELETE`` the resource.""" @@ -198,7 +198,7 @@ async def _action( json: _JsonPayload = None, query_params: QueryParam | None = None, options: QueryOptions | None = None, - ) -> ResourceModel: + ) -> ResourceModel | ModelCollection[ResourceModel]: response = await self.do_request( method, action, diff --git a/mpt_api_client/models/__init__.py b/mpt_api_client/models/__init__.py index 23e01e49..8c29ac9c 100644 --- a/mpt_api_client/models/__init__.py +++ b/mpt_api_client/models/__init__.py @@ -1,6 +1,6 @@ -from mpt_api_client.models.collection import Collection from mpt_api_client.models.file_model import FileModel from mpt_api_client.models.meta import Meta, Pagination from mpt_api_client.models.model import Model, ResourceData +from mpt_api_client.models.model_collection import ModelCollection -__all__ = ["Collection", "FileModel", "Meta", "Model", "Pagination", "ResourceData"] # noqa: WPS410 +__all__ = ["FileModel", "Meta", "Model", "ModelCollection", "Pagination", "ResourceData"] # noqa: WPS410 diff --git a/mpt_api_client/models/model.py b/mpt_api_client/models/model.py index 0da33232..e8ad5a46 100644 --- a/mpt_api_client/models/model.py +++ b/mpt_api_client/models/model.py @@ -6,6 +6,7 @@ from mpt_api_client.http.types import Response from mpt_api_client.models.meta import Meta +from mpt_api_client.models.model_collection import ModelCollection ResourceData = dict[str, Any] @@ -231,21 +232,19 @@ def __repr__(self) -> str: return f"<{class_name} {self.id}>" @classmethod - def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None) -> Self: - """Creates a new resource from ResourceData and Meta.""" - return cls(resource_data, meta=meta) - - @classmethod - def from_response(cls, response: Response) -> Self: + def from_response(cls, response: Response) -> Self | ModelCollection[Self]: """Creates a Model from a response. Args: response: The httpx response object. """ response_data = response.json() + if isinstance(response_data, dict): + meta = Meta.from_response(response) response_data.pop("$meta", None) - if not isinstance(response_data, dict): - raise TypeError("Response data must be a dict.") - meta = Meta.from_response(response) - return cls.new(response_data, meta) + return cls(response_data, meta) + if isinstance(response_data, list): + return ModelCollection([cls(data_item) for data_item in response_data]) + + raise TypeError(f"Incompatible response data type '{type(response_data).__name__}'.") diff --git a/mpt_api_client/models/collection.py b/mpt_api_client/models/model_collection.py similarity index 84% rename from mpt_api_client/models/collection.py rename to mpt_api_client/models/model_collection.py index 8c8c51fc..4910ac6b 100644 --- a/mpt_api_client/models/collection.py +++ b/mpt_api_client/models/model_collection.py @@ -1,12 +1,15 @@ from collections.abc import Iterator +from typing import TYPE_CHECKING from mpt_api_client.models.meta import Meta -from mpt_api_client.models.model import Model, ResourceData -ResourceList = list[ResourceData] +if TYPE_CHECKING: + from mpt_api_client.models.model import Model, ResourceData +ResourceList = list["ResourceData"] -class Collection[ItemType: Model]: + +class ModelCollection[ItemType: "Model"]: """Provides a collection to interact with api collection data using fluent interfaces.""" def __init__(self, resources: list[ItemType] | None = None, meta: Meta | None = None) -> None: diff --git a/mpt_api_client/resources/accounts/accounts_user_groups.py b/mpt_api_client/resources/accounts/accounts_user_groups.py index 1fa1062e..d63af07b 100644 --- a/mpt_api_client/resources/accounts/accounts_user_groups.py +++ b/mpt_api_client/resources/accounts/accounts_user_groups.py @@ -11,6 +11,7 @@ ) from mpt_api_client.models import Model from mpt_api_client.models.model import ResourceData +from mpt_api_client.models.model_collection import ModelCollection class AccountsUserGroup(Model): @@ -35,14 +36,17 @@ class AccountsUserGroupsService( ): """Account User Groups Service.""" - def update(self, resource_data: ResourceData) -> AccountsUserGroup: + def update( + self, resource_data: ResourceData + ) -> AccountsUserGroup | ModelCollection[AccountsUserGroup]: """Update Account User Group. Args: resource_data (ResourceData): Resource data to update. Returns: - AccountsUserGroup: Updated Account User Group. + AccountsUserGroup | ModelCollection[AccountsUserGroup]: Updated Account User Group, + or a ModelCollection[AccountsUserGroup] when the service returns a list response. """ response = self.http_client.request("put", self.path, json=resource_data) @@ -59,7 +63,9 @@ class AsyncAccountsUserGroupsService( ): """Asynchronous Account User Groups Service.""" - async def update(self, resource_data: ResourceData) -> AccountsUserGroup: + async def update( + self, resource_data: ResourceData + ) -> AccountsUserGroup | ModelCollection[AccountsUserGroup]: """Update Account User Group. Args: diff --git a/mpt_api_client/resources/billing/custom_ledgers.py b/mpt_api_client/resources/billing/custom_ledgers.py index 1ad473d1..a426548d 100644 --- a/mpt_api_client/resources/billing/custom_ledgers.py +++ b/mpt_api_client/resources/billing/custom_ledgers.py @@ -12,6 +12,7 @@ from mpt_api_client.http.types import FileContent, FileTypes from mpt_api_client.http.url_utils import join_url_path from mpt_api_client.models import Model +from mpt_api_client.models.model_collection import ModelCollection from mpt_api_client.resources.billing.custom_ledger_attachments import ( AsyncCustomLedgerAttachmentsService, CustomLedgerAttachmentsService, @@ -46,7 +47,9 @@ class CustomLedgersService( ): """Custom Ledgers service.""" - def upload(self, custom_ledger_id: str, file: FileTypes) -> CustomLedger: + def upload( + self, custom_ledger_id: str, file: FileTypes + ) -> CustomLedger | ModelCollection[CustomLedger]: """Upload custom ledger file. Args: @@ -54,7 +57,9 @@ def upload(self, custom_ledger_id: str, file: FileTypes) -> CustomLedger: file: Custom Ledger file. Returns: - CustomLedger: Created resource. + CustomLedger | ModelCollection[CustomLedger]: The uploaded resource as a single + CustomLedger instance, or a ModelCollection[CustomLedger] when the response contains + multiple records. """ files: dict[str, FileTypes] = {} @@ -105,7 +110,9 @@ class AsyncCustomLedgersService( ): """Async Custom Ledgers service.""" - async def upload(self, custom_ledger_id: str, file: FileTypes) -> CustomLedger: + async def upload( + self, custom_ledger_id: str, file: FileTypes + ) -> CustomLedger | ModelCollection[CustomLedger]: """Upload custom ledger file. Args: @@ -113,7 +120,9 @@ async def upload(self, custom_ledger_id: str, file: FileTypes) -> CustomLedger: file: Custom Ledger file. Returns: - CustomLedger: Created resource. + CustomLedger | ModelCollection[CustomLedger]: The uploaded resource as a single + CustomLedger instance, or a ModelCollection[CustomLedger] when the response contains + multiple records. """ files: dict[str, FileTypes] = {} diff --git a/mpt_api_client/resources/billing/journals.py b/mpt_api_client/resources/billing/journals.py index 6941b308..68544348 100644 --- a/mpt_api_client/resources/billing/journals.py +++ b/mpt_api_client/resources/billing/journals.py @@ -8,6 +8,7 @@ from mpt_api_client.http.types import FileTypes from mpt_api_client.http.url_utils import join_url_path from mpt_api_client.models import Model +from mpt_api_client.models.model_collection import ModelCollection from mpt_api_client.resources.billing.journal_attachments import ( AsyncJournalAttachmentsService, JournalAttachmentsService, @@ -46,7 +47,9 @@ class JournalsService( ): """Journals service.""" - def upload(self, journal_id: str, file: FileTypes | None = None) -> Journal: # noqa: WPS110 + def upload( + self, journal_id: str, file: FileTypes | None = None + ) -> Journal | ModelCollection[Journal]: # noqa: WPS110 """Upload journal file. Args: @@ -54,7 +57,8 @@ def upload(self, journal_id: str, file: FileTypes | None = None) -> Journal: # file: journal file. Returns: - Journal: Created resource. + Journal | ModelCollection[Journal]: The uploaded resource as a single Journal + instance, or a ModelCollection[Journal] when the response contains multiple records. """ files = {} @@ -104,7 +108,9 @@ class AsyncJournalsService( ): """Async Journals service.""" - async def upload(self, journal_id: str, file: FileTypes | None = None) -> Journal: # noqa: WPS110 + async def upload( + self, journal_id: str, file: FileTypes | None = None + ) -> Journal | ModelCollection[Journal]: # noqa: WPS110 """Upload journal file. Args: @@ -112,7 +118,8 @@ async def upload(self, journal_id: str, file: FileTypes | None = None) -> Journa file: journal file. Returns: - Journal: Created resource. + Journal | ModelCollection[Journal]: The uploaded resource as a single Journal + instance, or a ModelCollection[Journal] when the response contains multiple records. """ files = {} diff --git a/mpt_api_client/resources/notifications/batches.py b/mpt_api_client/resources/notifications/batches.py index 654024a7..dd258ae3 100644 --- a/mpt_api_client/resources/notifications/batches.py +++ b/mpt_api_client/resources/notifications/batches.py @@ -8,6 +8,7 @@ GetMixin, ) from mpt_api_client.models import FileModel, Model +from mpt_api_client.models.model_collection import ModelCollection class Batch(Model): @@ -37,7 +38,9 @@ class BatchesService( ): """Notifications Batches service.""" - def get_attachment(self, batch_id: str, attachment_id: str) -> BatchAttachment: + def get_attachment( + self, batch_id: str, attachment_id: str + ) -> BatchAttachment | ModelCollection[BatchAttachment]: """Get batch attachment. Args: @@ -45,7 +48,9 @@ def get_attachment(self, batch_id: str, attachment_id: str) -> BatchAttachment: attachment_id: Attachment ID. Returns: - BatchAttachment containing the attachment data. + BatchAttachment | ModelCollection[BatchAttachment]: A single BatchAttachment when + the response contains one record, or a ModelCollection[BatchAttachment] when the + response contains multiple attachments. """ response = self.http_client.request( "get", @@ -81,7 +86,9 @@ class AsyncBatchesService( ): """Async Notifications Batches service.""" - async def get_attachment(self, batch_id: str, attachment_id: str) -> BatchAttachment: + async def get_attachment( + self, batch_id: str, attachment_id: str + ) -> BatchAttachment | ModelCollection[BatchAttachment]: """Get batch attachment. Args: @@ -89,7 +96,9 @@ async def get_attachment(self, batch_id: str, attachment_id: str) -> BatchAttach attachment_id: Attachment ID. Returns: - BatchAttachment containing the attachment data. + BatchAttachment | ModelCollection[BatchAttachment]: A single BatchAttachment when + the response contains one record, or a ModelCollection[BatchAttachment] when the + response contains multiple attachments. """ response = await self.http_client.request( "get", diff --git a/tests/unit/models/collection/conftest.py b/tests/unit/models/collection/conftest.py index e8cc10ae..c21af6ef 100644 --- a/tests/unit/models/collection/conftest.py +++ b/tests/unit/models/collection/conftest.py @@ -1,6 +1,6 @@ import pytest -from mpt_api_client.models import Collection +from mpt_api_client.models import ModelCollection from tests.unit.conftest import DummyModel @@ -15,7 +15,7 @@ def response_collection_data(): @pytest.fixture def empty_collection(): - return Collection() + return ModelCollection() @pytest.fixture @@ -25,4 +25,4 @@ def collection_items(response_collection_data): @pytest.fixture def collection(collection_items): - return Collection(collection_items) + return ModelCollection(collection_items) diff --git a/tests/unit/models/test_model.py b/tests/unit/models/test_model.py index 8285efb6..e22c2d3d 100644 --- a/tests/unit/models/test_model.py +++ b/tests/unit/models/test_model.py @@ -1,7 +1,7 @@ import pytest from httpx import Response -from mpt_api_client.models import Meta, Model +from mpt_api_client.models import Meta, Model, ModelCollection from mpt_api_client.models.model import ( # noqa: WPS347 BaseModel, ModelList, @@ -86,10 +86,22 @@ def test_attribute_id(meta_data): assert resource.to_dict() == {"id": "R-1", "name": {"given": "Albert", "family": "Einstein"}} +def test_from_response_list(): + response_data = [{"id": "1"}, {"id": "2"}] + response = Response(200, json=response_data) + + result = Model.from_response(response) + + assert isinstance(result, ModelCollection) + for model in result: + assert isinstance(model, Model) + assert result.to_list() == response_data + + def test_wrong_data_type(): response = Response(200, json=1) - with pytest.raises(TypeError, match=r"Response data must be a dict."): + with pytest.raises(TypeError, match=r"Incompatible response data type 'int'."): Model.from_response(response)