From 23c437e23c90d70a1f59d483e9174fe329c406d9 Mon Sep 17 00:00:00 2001 From: Lukasz Lancucki Date: Wed, 15 Apr 2026 10:39:20 +0100 Subject: [PATCH 1/3] refactor(models): remove redundant `new` method and update usage --- mpt_api_client/http/base_service.py | 2 +- mpt_api_client/models/model.py | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/mpt_api_client/http/base_service.py b/mpt_api_client/http/base_service.py index b1d55563..4c80fb81 100644 --- a/mpt_api_client/http/base_service.py +++ b/mpt_api_client/http/base_service.py @@ -51,7 +51,7 @@ def make_collection(cls, response: Response) -> Collection[Model]: meta = Meta.from_response(response) return Collection( 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/models/model.py b/mpt_api_client/models/model.py index 0da33232..a54abc64 100644 --- a/mpt_api_client/models/model.py +++ b/mpt_api_client/models/model.py @@ -230,11 +230,6 @@ def __repr__(self) -> str: class_name = self.__class__.__name__ 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: """Creates a Model from a response. @@ -248,4 +243,4 @@ def from_response(cls, response: Response) -> Self: 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) From 98545dfb8304d9aa2763b4cdc7d3a6d49f68d7ca Mon Sep 17 00:00:00 2001 From: Lukasz Lancucki Date: Wed, 15 Apr 2026 12:23:45 +0100 Subject: [PATCH 2/3] refactor(models): rename `Collection` to `ModelCollection` and update references --- mpt_api_client/http/base_service.py | 6 +++--- mpt_api_client/http/mixins/collection_mixin.py | 6 +++--- mpt_api_client/http/resource_accessor.py | 2 +- mpt_api_client/models/__init__.py | 4 ++-- .../models/{collection.py => model_collection.py} | 2 +- tests/unit/models/collection/conftest.py | 6 +++--- 6 files changed, 13 insertions(+), 13 deletions(-) rename mpt_api_client/models/{collection.py => model_collection.py} (96%) diff --git a/mpt_api_client/http/base_service.py b/mpt_api_client/http/base_service.py index 4c80fb81..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,14 +42,14 @@ 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(resource, meta) for resource in response.json().get(cls._collection_key) 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..fc3a20c0 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 ResourceList _JsonPayload = ResourceData | ResourceList | None 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/collection.py b/mpt_api_client/models/model_collection.py similarity index 96% rename from mpt_api_client/models/collection.py rename to mpt_api_client/models/model_collection.py index 8c8c51fc..98ba9337 100644 --- a/mpt_api_client/models/collection.py +++ b/mpt_api_client/models/model_collection.py @@ -6,7 +6,7 @@ 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/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) From 7f3059dba1cce6b246a084b542a259e037e2c05c Mon Sep 17 00:00:00 2001 From: Lukasz Lancucki Date: Wed, 15 Apr 2026 12:30:30 +0100 Subject: [PATCH 3/3] feat(models): support list response in `from_response` method --- mpt_api_client/http/resource_accessor.py | 18 +++++++++--------- mpt_api_client/models/model.py | 14 +++++++++----- mpt_api_client/models/model_collection.py | 9 ++++++--- .../resources/accounts/accounts_user_groups.py | 12 +++++++++--- .../resources/billing/custom_ledgers.py | 17 +++++++++++++---- mpt_api_client/resources/billing/journals.py | 15 +++++++++++---- .../resources/notifications/batches.py | 17 +++++++++++++---- tests/unit/models/test_model.py | 16 ++++++++++++++-- 8 files changed, 84 insertions(+), 34 deletions(-) diff --git a/mpt_api_client/http/resource_accessor.py b/mpt_api_client/http/resource_accessor.py index fc3a20c0..9f267700 100644 --- a/mpt_api_client/http/resource_accessor.py +++ b/mpt_api_client/http/resource_accessor.py @@ -5,7 +5,7 @@ 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.model import Model, ResourceData # NOSONAR -from mpt_api_client.models.model_collection import ResourceList +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/model.py b/mpt_api_client/models/model.py index a54abc64..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,16 +232,19 @@ def __repr__(self) -> str: return f"<{class_name} {self.id}>" @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(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/model_collection.py b/mpt_api_client/models/model_collection.py index 98ba9337..4910ac6b 100644 --- a/mpt_api_client/models/model_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 ModelCollection[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/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)