-
Notifications
You must be signed in to change notification settings - Fork 0
#MPT-12328 Single result resource #5
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
Merged
Changes from all commits
Commits
Show all changes
3 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 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,100 @@ | ||
| import math | ||
| from dataclasses import dataclass, field | ||
| from typing import Any, ClassVar, Self, override | ||
|
|
||
| from box import Box | ||
| 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, | ||
| ) | ||
|
|
||
|
|
||
| ResourceData = dict[str, Any] | ||
|
|
||
|
|
||
| class GenericResource: | ||
| """Provides a base resource to interact with api data using fluent interfaces.""" | ||
|
|
||
albertsola marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| _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) | ||
|
|
||
| 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: | ||
| """Sets the resource data.""" | ||
| 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 | ||
| def from_response(cls, response: Response) -> Self: | ||
| """Creates a resource from a response. | ||
|
|
||
| Expected a Response with json data with two keys: data and $meta. | ||
| """ | ||
| 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(response_data, meta) | ||
|
|
||
| def to_dict(self) -> dict[str, Any]: | ||
| """Returns the resource as a dictionary.""" | ||
| 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,17 @@ | ||
| from httpx import Response | ||
|
|
||
| from mpt_api_client.http.models import GenericResource | ||
|
|
||
|
|
||
| class ChargeResourceMock(GenericResource): | ||
| _data_key = "charge" | ||
|
|
||
|
|
||
| def test_custom_data_key(): | ||
| record_data = {"id": 1, "amount": 100} | ||
| response = Response(200, json={"charge": record_data}) | ||
|
|
||
| resource = ChargeResourceMock.from_response(response) | ||
|
|
||
| assert resource.id == 1 | ||
| assert resource.amount == 100 |
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,61 @@ | ||||||||||||||||
| import pytest | ||||||||||||||||
| from httpx import Response | ||||||||||||||||
|
|
||||||||||||||||
| from mpt_api_client.http.models import GenericResource, Meta | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| @pytest.fixture | ||||||||||||||||
| def meta_data(): | ||||||||||||||||
| return {"pagination": {"limit": 10, "offset": 20, "total": 100}, "ignored": ["one"]} # noqa: WPS226 | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| def test_generic_resource_empty(): | ||||||||||||||||
| resource = GenericResource() | ||||||||||||||||
| assert resource.meta is None | ||||||||||||||||
| assert resource.to_dict() == {} | ||||||||||||||||
|
Comment on lines
+13
to
+15
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| def test_from_response(meta_data): | ||||||||||||||||
| record_data = {"id": 1, "name": {"given": "Albert", "family": "Einstein"}} | ||||||||||||||||
| response = Response(200, json={"data": record_data, "$meta": meta_data}) | ||||||||||||||||
| expected_meta = Meta.from_response(response) | ||||||||||||||||
|
|
||||||||||||||||
| resource = GenericResource.from_response(response) | ||||||||||||||||
|
|
||||||||||||||||
| assert resource.to_dict() == record_data | ||||||||||||||||
| assert resource.meta == expected_meta | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| def test_attribute_access(): | ||||||||||||||||
| resource_data = {"id": 1, "name": {"given": "Albert", "family": "Einstein"}} | ||||||||||||||||
| meta = Meta.from_response(Response(200, json={"$meta": {}})) | ||||||||||||||||
| resource = GenericResource(resource_data=resource_data, meta=meta) | ||||||||||||||||
|
|
||||||||||||||||
| assert resource.meta == meta | ||||||||||||||||
|
|
||||||||||||||||
| assert resource.id == 1 | ||||||||||||||||
|
|
||||||||||||||||
| with pytest.raises(AttributeError, match=r"'Box' object has no attribute 'address'"): | ||||||||||||||||
| resource.address # noqa: B018 | ||||||||||||||||
|
|
||||||||||||||||
| with pytest.raises(AttributeError, match=r"'Box' object has no attribute 'surname'"): | ||||||||||||||||
| resource.name.surname # noqa: B018 | ||||||||||||||||
|
|
||||||||||||||||
| assert resource.name.given == "Albert" | ||||||||||||||||
| assert resource.name.to_dict() == resource_data["name"] | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| def test_attribute_setter(): | ||||||||||||||||
| resource_data = {"id": 1, "name": {"given": "Albert", "family": "Einstein"}} | ||||||||||||||||
| resource = GenericResource(resource_data) | ||||||||||||||||
|
|
||||||||||||||||
| resource.id = 2 | ||||||||||||||||
| assert resource.id == 2 | ||||||||||||||||
|
|
||||||||||||||||
| resource.name.given = "John" | ||||||||||||||||
| assert resource.name.given == "John" | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| def test_wrong_data_type(): | ||||||||||||||||
| with pytest.raises(TypeError, match=r"Response data must be a dict."): | ||||||||||||||||
| GenericResource.from_response(Response(200, json={"data": 1})) | ||||||||||||||||
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,41 @@ | ||
| import pytest | ||
| from httpx import Response | ||
|
|
||
| from mpt_api_client.http.models import Meta, Pagination | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def responses_fixture(): | ||
| response_data = { | ||
| "$meta": { | ||
| "ignored": ["ignored"], | ||
| "pagination": {"limit": 25, "offset": 50, "total": 300}, | ||
| } | ||
| } | ||
| return Response(status_code=200, json=response_data) | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def invalid_response_fixture(): | ||
| response_data = {"$meta": "invalid_meta"} | ||
| return Response(status_code=200, json=response_data) | ||
|
|
||
|
|
||
| def test_meta_from_response(responses_fixture): | ||
| meta = Meta.from_response(responses_fixture) | ||
|
|
||
| assert isinstance(meta.pagination, Pagination) | ||
| assert meta.pagination == Pagination(limit=25, offset=50, total=300) | ||
|
|
||
|
|
||
| def test_invalid_meta_from_response(invalid_response_fixture): | ||
| with pytest.raises(TypeError, match=r"Response \$meta must be a dict."): | ||
| Meta.from_response(invalid_response_fixture) | ||
|
|
||
|
|
||
| def test_meta_with_pagination_object(): | ||
| response = Response(status_code=200, json={}) | ||
| pagination = Pagination(limit=10, offset=0, total=100) | ||
| meta = Meta(response=response, pagination=pagination) | ||
|
|
||
| assert meta.pagination == Pagination(limit=10, offset=0, total=100) |
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,85 @@ | ||
| import pytest | ||
|
|
||
| from mpt_api_client.http.models import Pagination | ||
|
|
||
|
|
||
| def test_default_page(): # noqa: WPS218 | ||
| pagination = Pagination() | ||
|
|
||
| assert pagination.limit == 0 | ||
| assert pagination.offset == 0 | ||
| assert pagination.total == 0 | ||
|
|
||
| assert pagination.has_next() is False | ||
| assert pagination.num_page() == 0 | ||
| assert pagination.total_pages() == 0 | ||
| assert pagination.next_offset() == 0 | ||
|
|
||
|
|
||
| def test_pagination_initialization(): | ||
| pagination = Pagination(limit=10, offset=0, total=100) | ||
|
|
||
| assert pagination.limit == 10 | ||
| assert pagination.offset == 0 | ||
| assert pagination.total == 100 | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| ("num_page", "total_pages", "expected_has_next"), | ||
| [ | ||
| (0, 0, False), | ||
| (1, 100, True), | ||
| (100, 1, False), | ||
| ], | ||
| ) | ||
| def test_has_next(mocker, num_page, total_pages, expected_has_next): | ||
| pagination = Pagination() | ||
| mocker.patch.object(pagination, "num_page", return_value=num_page) | ||
| mocker.patch.object(pagination, "total_pages", return_value=total_pages) | ||
|
|
||
| assert pagination.has_next() == expected_has_next | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| ("limit", "offset", "expected_page"), | ||
| [ | ||
| (0, 0, 0), | ||
| (1, 0, 0), | ||
| (5, 5, 1), | ||
| (10, 990, 99), | ||
| (245, 238, 0) | ||
| ], | ||
| ) | ||
| def test_num_page(limit, offset, expected_page): | ||
| pagination = Pagination(limit=limit, offset=offset, total=5) | ||
|
|
||
| assert pagination.num_page() == expected_page | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| ("limit", "total", "expected_total_pages"), | ||
| [ | ||
| (0, 0, 0), | ||
| (0, 2, 0), | ||
| (1, 1, 1), | ||
| (1, 2, 2), | ||
| ], | ||
| ) | ||
| def test_total_pages(limit, total, expected_total_pages): | ||
| pagination = Pagination(limit=limit, offset=0, total=total) | ||
|
|
||
| assert pagination.total_pages() == expected_total_pages | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| ("limit", "offset", "expected_next_offset"), | ||
| [ | ||
| (0, 0, 0), | ||
| (1, 0, 1), | ||
| (1, 2, 3), | ||
| ], | ||
| ) | ||
| def test_next_offset(limit, offset, expected_next_offset): | ||
| pagination = Pagination(limit=limit, offset=offset, total=3) | ||
|
|
||
| assert pagination.next_offset() == expected_next_offset |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
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.