Skip to content

Commit f7e44cd

Browse files
authored
#MPT-12328 Single result resource (#5)
2 parents 4d76872 + 605a911 commit f7e44cd

File tree

8 files changed

+334
-3
lines changed

8 files changed

+334
-3
lines changed

mpt_api_client/http/models.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import math
2+
from dataclasses import dataclass, field
3+
from typing import Any, ClassVar, Self, override
4+
5+
from box import Box
6+
from httpx import Response
7+
8+
9+
@dataclass
10+
class Pagination:
11+
"""Provides pagination information."""
12+
13+
limit: int = 0
14+
offset: int = 0
15+
total: int = 0
16+
17+
def has_next(self) -> bool:
18+
"""Returns True if there is a next page."""
19+
return self.num_page() + 1 < self.total_pages()
20+
21+
def num_page(self) -> int:
22+
"""Returns the current page number starting the first page as 0."""
23+
if self.limit == 0:
24+
return 0
25+
return self.offset // self.limit
26+
27+
def total_pages(self) -> int:
28+
"""Returns the total number of pages."""
29+
if self.limit == 0:
30+
return 0
31+
return math.ceil(self.total / self.limit)
32+
33+
def next_offset(self) -> int:
34+
"""Returns the next offset as an integer for the next page."""
35+
return self.offset + self.limit
36+
37+
38+
@dataclass
39+
class Meta:
40+
"""Provides meta-information about the pagination, ignored fields and the response."""
41+
42+
response: Response
43+
pagination: Pagination = field(default_factory=Pagination)
44+
ignored: list[str] = field(default_factory=list)
45+
46+
@classmethod
47+
def from_response(cls, response: Response) -> Self:
48+
"""Creates a meta object from response."""
49+
meta_data = response.json().get("$meta", {})
50+
if not isinstance(meta_data, dict):
51+
raise TypeError("Response $meta must be a dict.")
52+
53+
return cls(
54+
ignored=meta_data.get("ignored", []),
55+
pagination=Pagination(**meta_data.get("pagination", {})),
56+
response=response,
57+
)
58+
59+
60+
ResourceData = dict[str, Any]
61+
62+
63+
class GenericResource:
64+
"""Provides a base resource to interact with api data using fluent interfaces."""
65+
66+
_data_key: ClassVar[str] = "data"
67+
_safe_attributes: ClassVar[list[str]] = ["meta", "_resource_data"]
68+
69+
def __init__(self, resource_data: ResourceData | None = None, meta: Meta | None = None) -> None:
70+
self.meta = meta
71+
self._resource_data = Box(resource_data or {}, camel_killer_box=True, default_box=False)
72+
73+
def __getattr__(self, attribute: str) -> Box | Any:
74+
"""Returns the resource data."""
75+
return self._resource_data.__getattr__(attribute) # type: ignore[no-untyped-call]
76+
77+
@override
78+
def __setattr__(self, attribute: str, attribute_value: Any) -> None:
79+
"""Sets the resource data."""
80+
if attribute in self._safe_attributes:
81+
object.__setattr__(self, attribute, attribute_value)
82+
return
83+
84+
self._resource_data.__setattr__(attribute, attribute_value) # type: ignore[no-untyped-call]
85+
86+
@classmethod
87+
def from_response(cls, response: Response) -> Self:
88+
"""Creates a resource from a response.
89+
90+
Expected a Response with json data with two keys: data and $meta.
91+
"""
92+
response_data = response.json().get(cls._data_key)
93+
if not isinstance(response_data, dict):
94+
raise TypeError("Response data must be a dict.")
95+
meta = Meta.from_response(response)
96+
return cls(response_data, meta)
97+
98+
def to_dict(self) -> dict[str, Any]:
99+
"""Returns the resource as a dictionary."""
100+
return self._resource_data.to_dict()

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ classifiers = [
2020
"Topic :: Utilities",
2121
]
2222
dependencies = [
23-
"httpx==0.28.*"
23+
"httpx==0.28.*",
24+
"python-box>=7.3.2",
2425
]
2526

2627
[dependency-groups]
@@ -166,6 +167,8 @@ pydocstyle.convention = "google"
166167

167168
[tool.ruff.lint.per-file-ignores]
168169
"tests/*.py" = [
170+
"D101", # do not require docstrings in public classes
171+
"D102", # do not require docstrincs in public method
169172
"D103", # missing docstring in public function
170173
"PLR2004", # allow magic numbers in tests
171174
"S101", # asserts

setup.cfg

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,9 @@ extend-exclude =
2626
select = WPS, E999
2727

2828
per-file-ignores =
29-
tests/*: WPS432
29+
tests/*:
30+
# Allow string literal overuse
31+
WPS226
32+
33+
# Allow magic strings
34+
WPS432
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from httpx import Response
2+
3+
from mpt_api_client.http.models import GenericResource
4+
5+
6+
class ChargeResourceMock(GenericResource):
7+
_data_key = "charge"
8+
9+
10+
def test_custom_data_key():
11+
record_data = {"id": 1, "amount": 100}
12+
response = Response(200, json={"charge": record_data})
13+
14+
resource = ChargeResourceMock.from_response(response)
15+
16+
assert resource.id == 1
17+
assert resource.amount == 100
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import pytest
2+
from httpx import Response
3+
4+
from mpt_api_client.http.models import GenericResource, Meta
5+
6+
7+
@pytest.fixture
8+
def meta_data():
9+
return {"pagination": {"limit": 10, "offset": 20, "total": 100}, "ignored": ["one"]} # noqa: WPS226
10+
11+
12+
def test_generic_resource_empty():
13+
resource = GenericResource()
14+
assert resource.meta is None
15+
assert resource.to_dict() == {}
16+
17+
18+
def test_from_response(meta_data):
19+
record_data = {"id": 1, "name": {"given": "Albert", "family": "Einstein"}}
20+
response = Response(200, json={"data": record_data, "$meta": meta_data})
21+
expected_meta = Meta.from_response(response)
22+
23+
resource = GenericResource.from_response(response)
24+
25+
assert resource.to_dict() == record_data
26+
assert resource.meta == expected_meta
27+
28+
29+
def test_attribute_access():
30+
resource_data = {"id": 1, "name": {"given": "Albert", "family": "Einstein"}}
31+
meta = Meta.from_response(Response(200, json={"$meta": {}}))
32+
resource = GenericResource(resource_data=resource_data, meta=meta)
33+
34+
assert resource.meta == meta
35+
36+
assert resource.id == 1
37+
38+
with pytest.raises(AttributeError, match=r"'Box' object has no attribute 'address'"):
39+
resource.address # noqa: B018
40+
41+
with pytest.raises(AttributeError, match=r"'Box' object has no attribute 'surname'"):
42+
resource.name.surname # noqa: B018
43+
44+
assert resource.name.given == "Albert"
45+
assert resource.name.to_dict() == resource_data["name"]
46+
47+
48+
def test_attribute_setter():
49+
resource_data = {"id": 1, "name": {"given": "Albert", "family": "Einstein"}}
50+
resource = GenericResource(resource_data)
51+
52+
resource.id = 2
53+
assert resource.id == 2
54+
55+
resource.name.given = "John"
56+
assert resource.name.given == "John"
57+
58+
59+
def test_wrong_data_type():
60+
with pytest.raises(TypeError, match=r"Response data must be a dict."):
61+
GenericResource.from_response(Response(200, json={"data": 1}))

tests/http/models/test_meta.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import pytest
2+
from httpx import Response
3+
4+
from mpt_api_client.http.models import Meta, Pagination
5+
6+
7+
@pytest.fixture
8+
def responses_fixture():
9+
response_data = {
10+
"$meta": {
11+
"ignored": ["ignored"],
12+
"pagination": {"limit": 25, "offset": 50, "total": 300},
13+
}
14+
}
15+
return Response(status_code=200, json=response_data)
16+
17+
18+
@pytest.fixture
19+
def invalid_response_fixture():
20+
response_data = {"$meta": "invalid_meta"}
21+
return Response(status_code=200, json=response_data)
22+
23+
24+
def test_meta_from_response(responses_fixture):
25+
meta = Meta.from_response(responses_fixture)
26+
27+
assert isinstance(meta.pagination, Pagination)
28+
assert meta.pagination == Pagination(limit=25, offset=50, total=300)
29+
30+
31+
def test_invalid_meta_from_response(invalid_response_fixture):
32+
with pytest.raises(TypeError, match=r"Response \$meta must be a dict."):
33+
Meta.from_response(invalid_response_fixture)
34+
35+
36+
def test_meta_with_pagination_object():
37+
response = Response(status_code=200, json={})
38+
pagination = Pagination(limit=10, offset=0, total=100)
39+
meta = Meta(response=response, pagination=pagination)
40+
41+
assert meta.pagination == Pagination(limit=10, offset=0, total=100)
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import pytest
2+
3+
from mpt_api_client.http.models import Pagination
4+
5+
6+
def test_default_page(): # noqa: WPS218
7+
pagination = Pagination()
8+
9+
assert pagination.limit == 0
10+
assert pagination.offset == 0
11+
assert pagination.total == 0
12+
13+
assert pagination.has_next() is False
14+
assert pagination.num_page() == 0
15+
assert pagination.total_pages() == 0
16+
assert pagination.next_offset() == 0
17+
18+
19+
def test_pagination_initialization():
20+
pagination = Pagination(limit=10, offset=0, total=100)
21+
22+
assert pagination.limit == 10
23+
assert pagination.offset == 0
24+
assert pagination.total == 100
25+
26+
27+
@pytest.mark.parametrize(
28+
("num_page", "total_pages", "expected_has_next"),
29+
[
30+
(0, 0, False),
31+
(1, 100, True),
32+
(100, 1, False),
33+
],
34+
)
35+
def test_has_next(mocker, num_page, total_pages, expected_has_next):
36+
pagination = Pagination()
37+
mocker.patch.object(pagination, "num_page", return_value=num_page)
38+
mocker.patch.object(pagination, "total_pages", return_value=total_pages)
39+
40+
assert pagination.has_next() == expected_has_next
41+
42+
43+
@pytest.mark.parametrize(
44+
("limit", "offset", "expected_page"),
45+
[
46+
(0, 0, 0),
47+
(1, 0, 0),
48+
(5, 5, 1),
49+
(10, 990, 99),
50+
(245, 238, 0)
51+
],
52+
)
53+
def test_num_page(limit, offset, expected_page):
54+
pagination = Pagination(limit=limit, offset=offset, total=5)
55+
56+
assert pagination.num_page() == expected_page
57+
58+
59+
@pytest.mark.parametrize(
60+
("limit", "total", "expected_total_pages"),
61+
[
62+
(0, 0, 0),
63+
(0, 2, 0),
64+
(1, 1, 1),
65+
(1, 2, 2),
66+
],
67+
)
68+
def test_total_pages(limit, total, expected_total_pages):
69+
pagination = Pagination(limit=limit, offset=0, total=total)
70+
71+
assert pagination.total_pages() == expected_total_pages
72+
73+
74+
@pytest.mark.parametrize(
75+
("limit", "offset", "expected_next_offset"),
76+
[
77+
(0, 0, 0),
78+
(1, 0, 1),
79+
(1, 2, 3),
80+
],
81+
)
82+
def test_next_offset(limit, offset, expected_next_offset):
83+
pagination = Pagination(limit=limit, offset=offset, total=3)
84+
85+
assert pagination.next_offset() == expected_next_offset

uv.lock

Lines changed: 20 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)