-
Notifications
You must be signed in to change notification settings - Fork 0
MPT-12327 Implement generic Collection Result #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
albertsola
merged 2 commits into
main
from
asola/MPT-12327/Implement-generic-Collection-Result
Aug 11, 2025
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| from mpt_api_client.models.collection import Collection | ||
| from mpt_api_client.models.meta import Meta, Pagination | ||
| from mpt_api_client.models.resource import Resource | ||
|
|
||
| __all__ = ["Collection", "Meta", "Pagination", "Resource"] # noqa: WPS410 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| from abc import ABC, abstractmethod | ||
| from typing import Any, Self | ||
|
|
||
| from httpx import Response | ||
|
|
||
| from mpt_api_client.models.meta import Meta | ||
|
|
||
| ResourceData = dict[str, Any] | ||
|
|
||
|
|
||
| class BaseResource(ABC): | ||
| """Provides a base resource to interact with api data using fluent interfaces.""" | ||
|
|
||
| @classmethod | ||
| @abstractmethod | ||
| def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None) -> Self: | ||
| """Creates a new resource from ResourceData and Meta.""" | ||
| raise NotImplementedError | ||
|
|
||
| @classmethod | ||
| @abstractmethod | ||
| def from_response(cls, response: Response) -> Self: | ||
| """Creates a collection from a response. | ||
|
|
||
| Args: | ||
| response: The httpx response object. | ||
| """ | ||
| raise NotImplementedError | ||
|
|
||
| @abstractmethod | ||
| def to_dict(self) -> dict[str, Any]: | ||
| """Returns the resource as a dictionary.""" | ||
| raise NotImplementedError | ||
|
|
||
|
|
||
| class BaseCollection(ABC): | ||
| """Provides a base collection to interact with api collection data using fluent interfaces.""" | ||
|
|
||
| @classmethod | ||
| @abstractmethod | ||
| def from_response(cls, response: Response) -> Self: | ||
| """Creates a collection from a response. | ||
|
|
||
| Args: | ||
| response: The httpx response object. | ||
| """ | ||
| raise NotImplementedError | ||
|
|
||
| @abstractmethod | ||
| def to_list(self) -> list[dict[str, Any]]: | ||
| """Returns the collection as a list of dictionaries.""" | ||
| raise NotImplementedError |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| from collections.abc import Iterator | ||
| from typing import Any, ClassVar, Self, override | ||
|
|
||
| from httpx import Response | ||
|
|
||
| from mpt_api_client.models.base import BaseCollection, ResourceData | ||
| from mpt_api_client.models.meta import Meta | ||
| from mpt_api_client.models.resource import Resource | ||
|
|
||
|
|
||
| class Collection[ResourceType](BaseCollection): | ||
| """Provides a base collection to interact with api collection data using fluent interfaces.""" | ||
|
|
||
| _data_key: ClassVar[str] = "data" | ||
| _resource_model: type[Resource] = Resource | ||
|
|
||
| def __init__( | ||
| self, collection_data: list[ResourceData] | None = None, meta: Meta | None = None | ||
| ) -> None: | ||
| self.meta = meta | ||
| collection_data = collection_data or [] | ||
| self._resource_collection = [ | ||
| self._resource_model.new(resource_data, meta) for resource_data in collection_data | ||
| ] | ||
|
|
||
| def __getitem__(self, index: int) -> ResourceType: | ||
| """Returns the collection item at the given index.""" | ||
| return self._resource_collection[index] # type: ignore[return-value] | ||
|
|
||
| def __iter__(self) -> Iterator[ResourceType]: | ||
| """Make GenericCollection iterable.""" | ||
| return iter(self._resource_collection) # type: ignore[arg-type] | ||
|
|
||
| def __len__(self) -> int: | ||
| """Return the number of items in the collection.""" | ||
| return len(self._resource_collection) | ||
|
|
||
| def __bool__(self) -> bool: | ||
| """Returns True if collection has items.""" | ||
| return len(self._resource_collection) > 0 | ||
|
|
||
| @override | ||
| @classmethod | ||
| def from_response(cls, response: Response) -> Self: | ||
| response_data = response.json().get(cls._data_key) | ||
| meta = Meta.from_response(response) | ||
| if not isinstance(response_data, list): | ||
| raise TypeError(f"Response `{cls._data_key}` must be a list for collection endpoints.") | ||
|
|
||
| return cls(response_data, meta) | ||
|
|
||
| @override | ||
| def to_list(self) -> list[dict[str, Any]]: | ||
| return [resource.to_dict() for resource in self._resource_collection] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import math | ||
| from dataclasses import dataclass, field | ||
| from typing import Self | ||
|
|
||
| from httpx import Response | ||
|
|
||
|
|
||
| @dataclass | ||
| class Pagination: | ||
| """Provides pagination information.""" | ||
|
|
||
| limit: int = 0 | ||
| offset: int = 0 | ||
| total: int = 0 | ||
|
|
||
| def has_next(self) -> bool: | ||
| """Returns True if there is a next page.""" | ||
| return self.num_page() + 1 < self.total_pages() | ||
|
|
||
| def num_page(self) -> int: | ||
| """Returns the current page number starting the first page as 0.""" | ||
| if self.limit == 0: | ||
| return 0 | ||
| return self.offset // self.limit | ||
|
|
||
| def total_pages(self) -> int: | ||
| """Returns the total number of pages.""" | ||
| if self.limit == 0: | ||
| return 0 | ||
| return math.ceil(self.total / self.limit) | ||
|
|
||
| def next_offset(self) -> int: | ||
| """Returns the next offset as an integer for the next page.""" | ||
| return self.offset + self.limit | ||
|
|
||
|
|
||
| @dataclass | ||
| class Meta: | ||
| """Provides meta-information about the pagination, ignored fields and the response.""" | ||
|
|
||
| response: Response | ||
| pagination: Pagination = field(default_factory=Pagination) | ||
| ignored: list[str] = field(default_factory=list) | ||
|
|
||
| @classmethod | ||
| def from_response(cls, response: Response) -> Self: | ||
| """Creates a meta object from response.""" | ||
| meta_data = response.json().get("$meta", {}) | ||
| if not isinstance(meta_data, dict): | ||
| raise TypeError("Response $meta must be a dict.") | ||
|
|
||
| return cls( | ||
| ignored=meta_data.get("ignored", []), | ||
| pagination=Pagination(**meta_data.get("pagination", {})), | ||
| response=response, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| from typing import Any, ClassVar, Self, override | ||
|
|
||
| from box import Box | ||
| from httpx import Response | ||
|
|
||
| from mpt_api_client.models.base import BaseResource, ResourceData | ||
| from mpt_api_client.models.meta import Meta | ||
|
|
||
|
|
||
| class Resource(BaseResource): | ||
| """Provides a resource to interact with api data using fluent interfaces.""" | ||
|
|
||
| _data_key: ClassVar[str] = "data" | ||
| _safe_attributes: ClassVar[list[str]] = ["meta", "_resource_data"] | ||
|
|
||
| def __init__(self, resource_data: ResourceData | None = None, meta: Meta | None = None) -> None: | ||
| self.meta = meta | ||
| self._resource_data = Box(resource_data or {}, camel_killer_box=True, default_box=False) | ||
|
|
||
| @classmethod | ||
| @override | ||
| def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None) -> Self: | ||
| return cls(resource_data, meta) | ||
|
|
||
| def __getattr__(self, attribute: str) -> Box | Any: | ||
| """Returns the resource data.""" | ||
| return self._resource_data.__getattr__(attribute) # type: ignore[no-untyped-call] | ||
|
|
||
| @override | ||
| def __setattr__(self, attribute: str, attribute_value: Any) -> None: | ||
| if attribute in self._safe_attributes: | ||
| object.__setattr__(self, attribute, attribute_value) | ||
| return | ||
|
|
||
| self._resource_data.__setattr__(attribute, attribute_value) # type: ignore[no-untyped-call] | ||
|
|
||
| @classmethod | ||
| @override | ||
| def from_response(cls, response: Response) -> Self: | ||
| response_data = response.json().get(cls._data_key) | ||
| 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) | ||
|
|
||
| @override | ||
| def to_dict(self) -> dict[str, Any]: | ||
| return self._resource_data.to_dict() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import pytest | ||
|
|
||
| from mpt_api_client.models import Collection, Resource | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def meta_data(): | ||
| return {"pagination": {"limit": 10, "offset": 0, "total": 3}, "ignored": ["field1"]} | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def response_collection_data(): | ||
| return [ | ||
| {"id": 1, "user": {"name": "Alice", "surname": "Smith"}, "status": "active"}, | ||
| {"id": 2, "user": {"name": "Bob", "surname": "Johnson"}, "status": "inactive"}, | ||
| {"id": 3, "user": {"name": "Charlie", "surname": "Brown"}, "status": "active"}, | ||
| ] | ||
|
|
||
|
|
||
| TestCollection = Collection[Resource] | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def empty_collection(): | ||
| return TestCollection() | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def collection(response_collection_data): | ||
| return TestCollection(response_collection_data) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| from httpx import Response | ||
|
|
||
| from mpt_api_client.models.collection import Collection | ||
| from mpt_api_client.models.resource import Resource | ||
|
|
||
|
|
||
| class ChargeResourceMock(Collection[Resource]): | ||
| _data_key = "charge" | ||
|
|
||
|
|
||
| def charge(charge_id, amount) -> dict[str, int]: | ||
| return {"id": charge_id, "amount": amount} | ||
|
|
||
|
|
||
| def test_custom_data_key(): | ||
| payload = {"charge": [charge(1, 100), charge(2, 101)]} | ||
| response = Response(200, json=payload) | ||
|
|
||
| resource = ChargeResourceMock.from_response(response) | ||
|
|
||
| assert resource[0].to_dict() == charge(1, 100) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.