From e8696bf88840b9928271d899783e2a07c3c351d6 Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Fri, 1 Aug 2025 13:32:49 +0100 Subject: [PATCH 1/3] #MPT-12328 Single result result --- mpt_api_client/http/models.py | 86 ++++++++++++++++ pyproject.toml | 6 +- setup.cfg | 19 +++- tests/http/models/test_genric_resource.py | 110 +++++++++++++++++++++ tests/http/models/test_meta.py | 47 +++++++++ tests/http/models/test_pagination.py | 114 ++++++++++++++++++++++ uv.lock | 21 +++- 7 files changed, 400 insertions(+), 3 deletions(-) create mode 100644 mpt_api_client/http/models.py create mode 100644 tests/http/models/test_genric_resource.py create mode 100644 tests/http/models/test_meta.py create mode 100644 tests/http/models/test_pagination.py diff --git a/mpt_api_client/http/models.py b/mpt_api_client/http/models.py new file mode 100644 index 00000000..a1c07a30 --- /dev/null +++ b/mpt_api_client/http/models.py @@ -0,0 +1,86 @@ +import math +from dataclasses import dataclass, field +from typing import Any, Self + +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.offset + self.limit < self.total + + def num_page(self) -> int: + """Returns the current page number.""" + if self.limit == 0: + return 0 + return (self.offset // self.limit) + 1 + + 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.""" + + pagination: Pagination = field(default_factory=Pagination) + ignored: list[str] = field(default_factory=list) + response: Response | None = None + + @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, + ) + + +class GenericResource(Box): + """Provides a base resource to interact with api data using fluent interfaces.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.__post_init__() + + def __post_init__(self) -> None: + """Initializes meta information.""" + meta = self.get("$meta", None) # type: ignore[no-untyped-call] + if meta: + self._meta = Meta(**meta) + + @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("data") + if not isinstance(response_data, dict): + raise TypeError("Response data must be a dict.") + meta = Meta.from_response(response) + meta.response = response + resource = cls(response_data) + resource._meta = meta + return resource diff --git a/pyproject.toml b/pyproject.toml index 61948b20..e585bdd3 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 @@ -173,6 +176,7 @@ pydocstyle.convention = "google" "S404", # subprocess calls are for tests "S603", # do not require `shell=True` "S607", # partial executable paths + "SLF001", # Allow private property/method access ] [tool.mypy] diff --git a/setup.cfg b/setup.cfg index 19c1015d..6f85d958 100644 --- a/setup.cfg +++ b/setup.cfg @@ -26,4 +26,21 @@ extend-exclude = select = WPS, E999 per-file-ignores = - tests/*: WPS432 + tests/*: + # Allow private property/method access + SLF001 + + # Allow unused variables + WPS122 + + # Allow >7 methods + WPS214 + + # Allow string literal overuse + WPS226 + + # Allow magic strings + WPS432 + + # Allow noqa overuse + WPS402 diff --git a/tests/http/models/test_genric_resource.py b/tests/http/models/test_genric_resource.py new file mode 100644 index 00000000..523b70ba --- /dev/null +++ b/tests/http/models/test_genric_resource.py @@ -0,0 +1,110 @@ +import re + +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 + + +class TestGenericResource: # noqa: WPS214 + def test_generic_resource_empty(self): + resource = GenericResource() + with pytest.raises(AttributeError): + _ = resource._meta + + def test_initialization_with_data(self): + resource = GenericResource(name="test", value=123) + + assert resource.name == "test" + assert resource.value == 123 + + def test_init(self, meta_data): + resource = {"$meta": meta_data, "key": "value"} # noqa: WPS445 WPS517 + init_one = GenericResource(resource) + init_two = GenericResource(**resource) + assert init_one == init_two + + def test_generic_resource_meta_property_with_data(self, meta_data): + resource = GenericResource({"$meta": meta_data}) + assert resource._meta == Meta(**meta_data) + + def test_generic_resource_box_functionality(self): + resource = GenericResource(id=1, name="test_resource", nested={"key": "value"}) + + assert resource.id == 1 + assert resource.name == "test_resource" + assert resource.nested.key == "value" + + def test_with_both_meta_and_response(self, meta_data): + response = Response(200, json={}) + meta_data["response"] = response + meta_object = Meta(**meta_data) + + resource = GenericResource( + data="test_data", + **{"$meta": meta_data}, # noqa: WPS445 WPS517 + ) + + assert resource.data == "test_data" + assert resource._meta == meta_object + + def test_dynamic_attribute_access(self): + resource = GenericResource() + + resource.dynamic_field = "dynamic_value" + resource.nested_object = {"inner": "data"} + + assert resource.dynamic_field == "dynamic_value" + assert resource.nested_object.inner == "data" + + +class TestGenericResourceFromResponse: + @pytest.fixture + def meta_data_single(self): + return {"ignored": ["one"]} # noqa: WPS226 + + @pytest.fixture + def meta_data_two_resources(self): + return {"pagination": {"limit": 10, "offset": 0, "total": 2}, "ignored": ["one"]} # noqa: WPS226 + + @pytest.fixture + def meta_data_multiple(self): + return {"ignored": ["one", "two"]} # noqa: WPS226 + + @pytest.fixture + def single_resource_data(self): + return {"id": 1, "name": "test"} + + @pytest.fixture + def single_resource_response(self, single_resource_data, meta_data_single): + return Response(200, json={"data": single_resource_data, "$meta": meta_data_single}) + + @pytest.fixture + def multiple_resource_response(self, single_resource_data, meta_data_two_resources): + return Response( + 200, + json={ + "data": [single_resource_data, single_resource_data], + "$meta": meta_data_two_resources, + }, + ) + + def test_malformed_meta_response(self): + with pytest.raises(TypeError, match=re.escape("Response $meta must be a dict.")): + _resource = GenericResource.from_response(Response(200, json={"data": {}, "$meta": 4})) + + def test_single_resource(self, single_resource_response): + resource = GenericResource.from_response(single_resource_response) + assert resource.id == 1 + assert resource.name == "test" + assert isinstance(resource._meta, Meta) + assert resource._meta.response == single_resource_response + + def test_two_resources(self, multiple_resource_response, single_resource_data): + with pytest.raises(TypeError, match=r"Response data must be a dict."): + _resource = GenericResource.from_response(multiple_resource_response) diff --git a/tests/http/models/test_meta.py b/tests/http/models/test_meta.py new file mode 100644 index 00000000..e1ceff60 --- /dev/null +++ b/tests/http/models/test_meta.py @@ -0,0 +1,47 @@ +import pytest +from httpx import Response + +from mpt_api_client.http.models import Meta, Pagination + + +class TestMeta: + + @pytest.fixture + def responses_fixture(self): + 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(self): + response_data = { + "$meta": "invalid_meta" + } + return Response(status_code=200, json=response_data) + + def test_meta_initialization_empty(self): + meta = Meta() + assert meta.pagination == Pagination(limit=0, offset=0, total=0) + + def test_meta_from_response(self, responses_fixture): + meta = Meta.from_response(responses_fixture) + + assert isinstance(meta.pagination, Pagination) + assert meta.pagination.limit == 25 + assert meta.pagination.offset == 50 + assert meta.pagination.total == 300 + + def test_invalid_meta_from_response(self, invalid_response_fixture): + with pytest.raises(TypeError): + Meta.from_response(invalid_response_fixture) + + def test_meta_with_pagination_object(self): + pagination = Pagination(limit=10, offset=0, total=100) + meta = Meta(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..5637f5af --- /dev/null +++ b/tests/http/models/test_pagination.py @@ -0,0 +1,114 @@ +import pytest + +from mpt_api_client.http.models import Pagination + + +class TestPagination: # noqa: WPS214 + def test_default_page(self): # 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(self): + pagination = Pagination(limit=10, offset=0, total=100) + + assert pagination.limit == 10 + assert pagination.offset == 0 + assert pagination.total == 100 + + def test_has_next_with_more_items(self): + pagination = Pagination(limit=10, offset=0, total=100) + + assert pagination.has_next() is True + + def test_has_next_with_no_more_items(self): + pagination = Pagination(limit=10, offset=90, total=100) + + assert pagination.has_next() is False + + def test_has_next_exact_boundary(self): + pagination = Pagination(limit=25, offset=75, total=100) + + assert pagination.has_next() is False + + def test_num_page_first_page(self): + pagination = Pagination(limit=10, offset=0, total=100) + + assert pagination.num_page() == 1 + + def test_num_page_middle_page(self): + pagination = Pagination(limit=10, offset=20, total=100) + + assert pagination.num_page() == 3 + + def test_num_page_last_page(self): + pagination = Pagination(limit=10, offset=90, total=100) + + assert pagination.num_page() == 10 + + def test_total_pages_even_division(self): + pagination = Pagination(limit=10, offset=0, total=100) + + assert pagination.total_pages() == 10 + + def test_total_pages_with_remainder(self): + pagination = Pagination(limit=10, offset=0, total=95) + + assert pagination.total_pages() == 10 + + def test_total_pages_single_item(self): + pagination = Pagination(limit=10, offset=0, total=1) + + assert pagination.total_pages() == 1 + + def test_total_pages_empty(self): + pagination = Pagination(limit=10, offset=0, total=0) + + assert pagination.total_pages() == 0 + + def test_next_offset_calculation(self): + pagination = Pagination(limit=25, offset=50, total=200) + + assert pagination.next_offset() == 75 + + def test_next_offset_from_start(self): + pagination = Pagination(limit=10, offset=0, total=100) + + assert pagination.next_offset() == 10 + + @pytest.mark.parametrize( + ("limit", "offset", "total", "expected_has_next"), + [ + (10, 0, 50, True), + (10, 40, 50, False), + (20, 0, 20, False), + (5, 45, 50, False), + (15, 30, 50, True), + ], + ) + def test_has_next_parametrized(self, limit, offset, total, expected_has_next): + pagination = Pagination(limit=limit, offset=offset, total=total) + + assert pagination.has_next() == expected_has_next + + @pytest.mark.parametrize( + ("limit", "offset", "expected_page"), + [ + (10, 0, 1), + (10, 10, 2), + (10, 25, 3), + (20, 40, 3), + (5, 47, 10), + ], + ) + def test_num_page_parametrized(self, limit, offset, expected_page): + pagination = Pagination(limit=limit, offset=offset, total=100) + + assert pagination.num_page() == expected_page 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" From 546f0dbfe2acdf3d0a457dc5092b9030feb43c22 Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Mon, 4 Aug 2025 11:48:01 +0100 Subject: [PATCH 2/3] Remove qa exceptions from tests --- pyproject.toml | 1 - setup.cfg | 12 ------------ 2 files changed, 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e585bdd3..94e09db7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -176,7 +176,6 @@ pydocstyle.convention = "google" "S404", # subprocess calls are for tests "S603", # do not require `shell=True` "S607", # partial executable paths - "SLF001", # Allow private property/method access ] [tool.mypy] diff --git a/setup.cfg b/setup.cfg index 6f85d958..dad410b5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,20 +27,8 @@ select = WPS, E999 per-file-ignores = tests/*: - # Allow private property/method access - SLF001 - - # Allow unused variables - WPS122 - - # Allow >7 methods - WPS214 - # Allow string literal overuse WPS226 # Allow magic strings WPS432 - - # Allow noqa overuse - WPS402 From 605a91104e55a3be8295f756bf1a77ad30df393e Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Mon, 4 Aug 2025 14:00:02 +0100 Subject: [PATCH 3/3] Updated generic resource and tests --- mpt_api_client/http/models.py | 56 ++++-- .../test_generic_resource_custom_key.py | 17 ++ tests/http/models/test_genric_resource.py | 115 +++-------- tests/http/models/test_meta.py | 58 +++--- tests/http/models/test_pagination.py | 189 ++++++++---------- 5 files changed, 191 insertions(+), 244 deletions(-) create mode 100644 tests/http/models/test_generic_resource_custom_key.py diff --git a/mpt_api_client/http/models.py b/mpt_api_client/http/models.py index a1c07a30..7da2d664 100644 --- a/mpt_api_client/http/models.py +++ b/mpt_api_client/http/models.py @@ -1,6 +1,6 @@ import math from dataclasses import dataclass, field -from typing import Any, Self +from typing import Any, ClassVar, Self, override from box import Box from httpx import Response @@ -16,13 +16,13 @@ class Pagination: def has_next(self) -> bool: """Returns True if there is a next page.""" - return self.offset + self.limit < self.total + return self.num_page() + 1 < self.total_pages() def num_page(self) -> int: - """Returns the current page number.""" + """Returns the current page number starting the first page as 0.""" if self.limit == 0: return 0 - return (self.offset // self.limit) + 1 + return self.offset // self.limit def total_pages(self) -> int: """Returns the total number of pages.""" @@ -37,16 +37,16 @@ def next_offset(self) -> int: @dataclass class Meta: - """Provides meta information about the pagination, ignored fields and the response.""" + """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) - response: Response | None = None @classmethod def from_response(cls, response: Response) -> Self: """Creates a meta object from response.""" - meta_data = response.json().get("$meta") + meta_data = response.json().get("$meta", {}) if not isinstance(meta_data, dict): raise TypeError("Response $meta must be a dict.") @@ -57,18 +57,31 @@ def from_response(cls, response: Response) -> Self: ) -class GenericResource(Box): +ResourceData = dict[str, Any] + + +class GenericResource: """Provides a base resource to interact with api data using fluent interfaces.""" - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - self.__post_init__() + _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 __post_init__(self) -> None: - """Initializes meta information.""" - meta = self.get("$meta", None) # type: ignore[no-untyped-call] - if meta: - self._meta = Meta(**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: + """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: @@ -76,11 +89,12 @@ def from_response(cls, response: Response) -> Self: Expected a Response with json data with two keys: data and $meta. """ - response_data = response.json().get("data") + 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) - meta.response = response - resource = cls(response_data) - resource._meta = meta - return resource + 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/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 index 523b70ba..321ce560 100644 --- a/tests/http/models/test_genric_resource.py +++ b/tests/http/models/test_genric_resource.py @@ -1,5 +1,3 @@ -import re - import pytest from httpx import Response @@ -11,100 +9,53 @@ def meta_data(): return {"pagination": {"limit": 10, "offset": 20, "total": 100}, "ignored": ["one"]} # noqa: WPS226 -class TestGenericResource: # noqa: WPS214 - def test_generic_resource_empty(self): - resource = GenericResource() - with pytest.raises(AttributeError): - _ = resource._meta - - def test_initialization_with_data(self): - resource = GenericResource(name="test", value=123) - - assert resource.name == "test" - assert resource.value == 123 - - def test_init(self, meta_data): - resource = {"$meta": meta_data, "key": "value"} # noqa: WPS445 WPS517 - init_one = GenericResource(resource) - init_two = GenericResource(**resource) - assert init_one == init_two - - def test_generic_resource_meta_property_with_data(self, meta_data): - resource = GenericResource({"$meta": meta_data}) - assert resource._meta == Meta(**meta_data) - - def test_generic_resource_box_functionality(self): - resource = GenericResource(id=1, name="test_resource", nested={"key": "value"}) +def test_generic_resource_empty(): + resource = GenericResource() + assert resource.meta is None + assert resource.to_dict() == {} - assert resource.id == 1 - assert resource.name == "test_resource" - assert resource.nested.key == "value" - def test_with_both_meta_and_response(self, meta_data): - response = Response(200, json={}) - meta_data["response"] = response - meta_object = Meta(**meta_data) +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( - data="test_data", - **{"$meta": meta_data}, # noqa: WPS445 WPS517 - ) + resource = GenericResource.from_response(response) - assert resource.data == "test_data" - assert resource._meta == meta_object + assert resource.to_dict() == record_data + assert resource.meta == expected_meta - def test_dynamic_attribute_access(self): - resource = GenericResource() - resource.dynamic_field = "dynamic_value" - resource.nested_object = {"inner": "data"} +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.dynamic_field == "dynamic_value" - assert resource.nested_object.inner == "data" + assert resource.meta == meta + assert resource.id == 1 -class TestGenericResourceFromResponse: - @pytest.fixture - def meta_data_single(self): - return {"ignored": ["one"]} # noqa: WPS226 + with pytest.raises(AttributeError, match=r"'Box' object has no attribute 'address'"): + resource.address # noqa: B018 - @pytest.fixture - def meta_data_two_resources(self): - return {"pagination": {"limit": 10, "offset": 0, "total": 2}, "ignored": ["one"]} # noqa: WPS226 + with pytest.raises(AttributeError, match=r"'Box' object has no attribute 'surname'"): + resource.name.surname # noqa: B018 - @pytest.fixture - def meta_data_multiple(self): - return {"ignored": ["one", "two"]} # noqa: WPS226 + assert resource.name.given == "Albert" + assert resource.name.to_dict() == resource_data["name"] - @pytest.fixture - def single_resource_data(self): - return {"id": 1, "name": "test"} - @pytest.fixture - def single_resource_response(self, single_resource_data, meta_data_single): - return Response(200, json={"data": single_resource_data, "$meta": meta_data_single}) +def test_attribute_setter(): + resource_data = {"id": 1, "name": {"given": "Albert", "family": "Einstein"}} + resource = GenericResource(resource_data) - @pytest.fixture - def multiple_resource_response(self, single_resource_data, meta_data_two_resources): - return Response( - 200, - json={ - "data": [single_resource_data, single_resource_data], - "$meta": meta_data_two_resources, - }, - ) + resource.id = 2 + assert resource.id == 2 - def test_malformed_meta_response(self): - with pytest.raises(TypeError, match=re.escape("Response $meta must be a dict.")): - _resource = GenericResource.from_response(Response(200, json={"data": {}, "$meta": 4})) + resource.name.given = "John" + assert resource.name.given == "John" - def test_single_resource(self, single_resource_response): - resource = GenericResource.from_response(single_resource_response) - assert resource.id == 1 - assert resource.name == "test" - assert isinstance(resource._meta, Meta) - assert resource._meta.response == single_resource_response - def test_two_resources(self, multiple_resource_response, single_resource_data): - with pytest.raises(TypeError, match=r"Response data must be a dict."): - _resource = GenericResource.from_response(multiple_resource_response) +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 index e1ceff60..d31effdf 100644 --- a/tests/http/models/test_meta.py +++ b/tests/http/models/test_meta.py @@ -4,44 +4,38 @@ from mpt_api_client.http.models import Meta, Pagination -class TestMeta: +@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 responses_fixture(self): - 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) - @pytest.fixture - def invalid_response_fixture(self): - response_data = { - "$meta": "invalid_meta" - } - return Response(status_code=200, json=response_data) - def test_meta_initialization_empty(self): - meta = Meta() - assert meta.pagination == Pagination(limit=0, offset=0, total=0) +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_meta_from_response(self, responses_fixture): - meta = Meta.from_response(responses_fixture) - assert isinstance(meta.pagination, Pagination) - assert meta.pagination.limit == 25 - assert meta.pagination.offset == 50 - assert meta.pagination.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_invalid_meta_from_response(self, invalid_response_fixture): - with pytest.raises(TypeError): - Meta.from_response(invalid_response_fixture) - def test_meta_with_pagination_object(self): - pagination = Pagination(limit=10, offset=0, total=100) - meta = Meta(pagination=pagination) +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) + 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 index 5637f5af..d165dbf9 100644 --- a/tests/http/models/test_pagination.py +++ b/tests/http/models/test_pagination.py @@ -3,112 +3,83 @@ from mpt_api_client.http.models import Pagination -class TestPagination: # noqa: WPS214 - def test_default_page(self): # 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(self): - pagination = Pagination(limit=10, offset=0, total=100) - - assert pagination.limit == 10 - assert pagination.offset == 0 - assert pagination.total == 100 - - def test_has_next_with_more_items(self): - pagination = Pagination(limit=10, offset=0, total=100) - - assert pagination.has_next() is True - - def test_has_next_with_no_more_items(self): - pagination = Pagination(limit=10, offset=90, total=100) - - assert pagination.has_next() is False - - def test_has_next_exact_boundary(self): - pagination = Pagination(limit=25, offset=75, total=100) - - assert pagination.has_next() is False - - def test_num_page_first_page(self): - pagination = Pagination(limit=10, offset=0, total=100) - - assert pagination.num_page() == 1 - - def test_num_page_middle_page(self): - pagination = Pagination(limit=10, offset=20, total=100) - - assert pagination.num_page() == 3 - - def test_num_page_last_page(self): - pagination = Pagination(limit=10, offset=90, total=100) - - assert pagination.num_page() == 10 - - def test_total_pages_even_division(self): - pagination = Pagination(limit=10, offset=0, total=100) - - assert pagination.total_pages() == 10 - - def test_total_pages_with_remainder(self): - pagination = Pagination(limit=10, offset=0, total=95) - - assert pagination.total_pages() == 10 - - def test_total_pages_single_item(self): - pagination = Pagination(limit=10, offset=0, total=1) - - assert pagination.total_pages() == 1 - - def test_total_pages_empty(self): - pagination = Pagination(limit=10, offset=0, total=0) - - assert pagination.total_pages() == 0 - - def test_next_offset_calculation(self): - pagination = Pagination(limit=25, offset=50, total=200) - - assert pagination.next_offset() == 75 - - def test_next_offset_from_start(self): - pagination = Pagination(limit=10, offset=0, total=100) - - assert pagination.next_offset() == 10 - - @pytest.mark.parametrize( - ("limit", "offset", "total", "expected_has_next"), - [ - (10, 0, 50, True), - (10, 40, 50, False), - (20, 0, 20, False), - (5, 45, 50, False), - (15, 30, 50, True), - ], - ) - def test_has_next_parametrized(self, limit, offset, total, expected_has_next): - pagination = Pagination(limit=limit, offset=offset, total=total) - - assert pagination.has_next() == expected_has_next - - @pytest.mark.parametrize( - ("limit", "offset", "expected_page"), - [ - (10, 0, 1), - (10, 10, 2), - (10, 25, 3), - (20, 40, 3), - (5, 47, 10), - ], - ) - def test_num_page_parametrized(self, limit, offset, expected_page): - pagination = Pagination(limit=limit, offset=offset, total=100) - - assert pagination.num_page() == expected_page +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