diff --git a/mpt_api_client/http/models.py b/mpt_api_client/http/models.py new file mode 100644 index 00000000..7da2d664 --- /dev/null +++ b/mpt_api_client/http/models.py @@ -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.""" + + _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() diff --git a/pyproject.toml b/pyproject.toml index 61948b20..94e09db7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,8 @@ classifiers = [ "Topic :: Utilities", ] dependencies = [ - "httpx==0.28.*" + "httpx==0.28.*", + "python-box>=7.3.2", ] [dependency-groups] @@ -166,6 +167,8 @@ pydocstyle.convention = "google" [tool.ruff.lint.per-file-ignores] "tests/*.py" = [ + "D101", # do not require docstrings in public classes + "D102", # do not require docstrincs in public method "D103", # missing docstring in public function "PLR2004", # allow magic numbers in tests "S101", # asserts diff --git a/setup.cfg b/setup.cfg index 19c1015d..dad410b5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -26,4 +26,9 @@ extend-exclude = select = WPS, E999 per-file-ignores = - tests/*: WPS432 + tests/*: + # Allow string literal overuse + WPS226 + + # Allow magic strings + WPS432 diff --git a/tests/http/models/test_generic_resource_custom_key.py b/tests/http/models/test_generic_resource_custom_key.py new file mode 100644 index 00000000..9da662f8 --- /dev/null +++ b/tests/http/models/test_generic_resource_custom_key.py @@ -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 diff --git a/tests/http/models/test_genric_resource.py b/tests/http/models/test_genric_resource.py new file mode 100644 index 00000000..321ce560 --- /dev/null +++ b/tests/http/models/test_genric_resource.py @@ -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() == {} + + +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})) diff --git a/tests/http/models/test_meta.py b/tests/http/models/test_meta.py new file mode 100644 index 00000000..d31effdf --- /dev/null +++ b/tests/http/models/test_meta.py @@ -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) diff --git a/tests/http/models/test_pagination.py b/tests/http/models/test_pagination.py new file mode 100644 index 00000000..d165dbf9 --- /dev/null +++ b/tests/http/models/test_pagination.py @@ -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 diff --git a/uv.lock b/uv.lock index ca004e35..6511b929 100644 --- a/uv.lock +++ b/uv.lock @@ -380,6 +380,7 @@ version = "1.0.0" source = { editable = "." } dependencies = [ { name = "httpx" }, + { name = "python-box" }, ] [package.dev-dependencies] @@ -403,7 +404,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "httpx", specifier = "==0.28.*" }] +requires-dist = [ + { name = "httpx", specifier = "==0.28.*" }, + { name = "python-box", specifier = ">=7.3.2" }, +] [package.metadata.requires-dev] dev = [ @@ -666,6 +670,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/82/1d96bf03ee4c0fdc3c0cbe61470070e659ca78dc0086fb88b66c185e2449/pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7", size = 46108, upload-time = "2024-04-28T19:29:52.813Z" }, ] +[[package]] +name = "python-box" +version = "7.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/f7/635eed8c500adf26208e86e985bbffb6ff039cd8950e3a4749ceca904218/python_box-7.3.2.tar.gz", hash = "sha256:028b9917129e67f311932d93347b8a4f1b500d7a5a2870ee3c035f4e7b19403b", size = 45771, upload-time = "2025-01-16T19:10:05.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/68/0c2f289d8055d3e1b156ff258847f0e8f1010063e284cf5a612f09435575/python_box-7.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39009a2da5c20133718b24891a206592adbe09169856aedc450ad1600fc2e511", size = 1819681, upload-time = "2025-01-16T19:10:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5d/76b4d6d0e41edb676a229f032848a1ecea166890fa8d501513ea1a030f4d/python_box-7.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e2a72e2f6fb97c7e472ff3272da207ecc615aa222e52e98352391428527c469", size = 4270424, upload-time = "2025-01-16T19:15:32.376Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6b/32484b2a3cd2fb5e5f56bfb53a4537d93a4d2014ccf7fc0c0017fa6f65e9/python_box-7.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:9eead914b9fb7d98a1473f5027dcfe27d26b3a10ffa33b9ba22cf948a23cd280", size = 1211252, upload-time = "2025-01-16T19:11:00.248Z" }, + { url = "https://files.pythonhosted.org/packages/2f/39/8bec609e93dbc5e0d3ea26cfb5af3ca78915f7a55ef5414713462fedeb59/python_box-7.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1dfc3b9b073f3d7cad1fa90de98eaaa684a494d0574bbc0666f74fa8307fd6b6", size = 1804675, upload-time = "2025-01-16T19:10:23.281Z" }, + { url = "https://files.pythonhosted.org/packages/88/ae/baf3a8057d8129896a7e02619df43ea0d918fc5b2bb66eb6e2470595fbac/python_box-7.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ca4685a7f764b5a71b6e08535ce2a96b7964bb63d8cb4df10f6bb7147b6c54b", size = 4265645, upload-time = "2025-01-16T19:15:34.087Z" }, + { url = "https://files.pythonhosted.org/packages/43/90/72367e03033c11a5e82676ee389b572bf136647ff4e3081557392b37e1ad/python_box-7.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e143295f74d47a9ab24562ead2375c9be10629599b57f2e86717d3fff60f82a9", size = 1206740, upload-time = "2025-01-16T19:11:30.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/13/8a990c6e2b6cc12700dce16f3cb383324e6d9a30f604eca22a2fdf84c923/python_box-7.3.2-py3-none-any.whl", hash = "sha256:fd7d74d5a848623f93b5221fd9fb00b8c00ff0e130fa87f396277aa188659c92", size = 29479, upload-time = "2025-01-16T19:10:02.749Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"