Skip to content

Commit e8696bf

Browse files
committed
#MPT-12328 Single result result
1 parent 4d76872 commit e8696bf

7 files changed

Lines changed: 400 additions & 3 deletions

File tree

mpt_api_client/http/models.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import math
2+
from dataclasses import dataclass, field
3+
from typing import Any, Self
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.offset + self.limit < self.total
20+
21+
def num_page(self) -> int:
22+
"""Returns the current page number."""
23+
if self.limit == 0:
24+
return 0
25+
return (self.offset // self.limit) + 1
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+
pagination: Pagination = field(default_factory=Pagination)
43+
ignored: list[str] = field(default_factory=list)
44+
response: Response | None = None
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+
class GenericResource(Box):
61+
"""Provides a base resource to interact with api data using fluent interfaces."""
62+
63+
def __init__(self, *args: Any, **kwargs: Any) -> None:
64+
super().__init__(*args, **kwargs)
65+
self.__post_init__()
66+
67+
def __post_init__(self) -> None:
68+
"""Initializes meta information."""
69+
meta = self.get("$meta", None) # type: ignore[no-untyped-call]
70+
if meta:
71+
self._meta = Meta(**meta)
72+
73+
@classmethod
74+
def from_response(cls, response: Response) -> Self:
75+
"""Creates a resource from a response.
76+
77+
Expected a Response with json data with two keys: data and $meta.
78+
"""
79+
response_data = response.json().get("data")
80+
if not isinstance(response_data, dict):
81+
raise TypeError("Response data must be a dict.")
82+
meta = Meta.from_response(response)
83+
meta.response = response
84+
resource = cls(response_data)
85+
resource._meta = meta
86+
return resource

pyproject.toml

Lines changed: 5 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,13 +167,16 @@ 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
172175
"S105", # hardcoded passwords
173176
"S404", # subprocess calls are for tests
174177
"S603", # do not require `shell=True`
175178
"S607", # partial executable paths
179+
"SLF001", # Allow private property/method access
176180
]
177181

178182
[tool.mypy]

setup.cfg

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

2828
per-file-ignores =
29-
tests/*: WPS432
29+
tests/*:
30+
# Allow private property/method access
31+
SLF001
32+
33+
# Allow unused variables
34+
WPS122
35+
36+
# Allow >7 methods
37+
WPS214
38+
39+
# Allow string literal overuse
40+
WPS226
41+
42+
# Allow magic strings
43+
WPS432
44+
45+
# Allow noqa overuse
46+
WPS402
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import re
2+
3+
import pytest
4+
from httpx import Response
5+
6+
from mpt_api_client.http.models import GenericResource, Meta
7+
8+
9+
@pytest.fixture
10+
def meta_data():
11+
return {"pagination": {"limit": 10, "offset": 20, "total": 100}, "ignored": ["one"]} # noqa: WPS226
12+
13+
14+
class TestGenericResource: # noqa: WPS214
15+
def test_generic_resource_empty(self):
16+
resource = GenericResource()
17+
with pytest.raises(AttributeError):
18+
_ = resource._meta
19+
20+
def test_initialization_with_data(self):
21+
resource = GenericResource(name="test", value=123)
22+
23+
assert resource.name == "test"
24+
assert resource.value == 123
25+
26+
def test_init(self, meta_data):
27+
resource = {"$meta": meta_data, "key": "value"} # noqa: WPS445 WPS517
28+
init_one = GenericResource(resource)
29+
init_two = GenericResource(**resource)
30+
assert init_one == init_two
31+
32+
def test_generic_resource_meta_property_with_data(self, meta_data):
33+
resource = GenericResource({"$meta": meta_data})
34+
assert resource._meta == Meta(**meta_data)
35+
36+
def test_generic_resource_box_functionality(self):
37+
resource = GenericResource(id=1, name="test_resource", nested={"key": "value"})
38+
39+
assert resource.id == 1
40+
assert resource.name == "test_resource"
41+
assert resource.nested.key == "value"
42+
43+
def test_with_both_meta_and_response(self, meta_data):
44+
response = Response(200, json={})
45+
meta_data["response"] = response
46+
meta_object = Meta(**meta_data)
47+
48+
resource = GenericResource(
49+
data="test_data",
50+
**{"$meta": meta_data}, # noqa: WPS445 WPS517
51+
)
52+
53+
assert resource.data == "test_data"
54+
assert resource._meta == meta_object
55+
56+
def test_dynamic_attribute_access(self):
57+
resource = GenericResource()
58+
59+
resource.dynamic_field = "dynamic_value"
60+
resource.nested_object = {"inner": "data"}
61+
62+
assert resource.dynamic_field == "dynamic_value"
63+
assert resource.nested_object.inner == "data"
64+
65+
66+
class TestGenericResourceFromResponse:
67+
@pytest.fixture
68+
def meta_data_single(self):
69+
return {"ignored": ["one"]} # noqa: WPS226
70+
71+
@pytest.fixture
72+
def meta_data_two_resources(self):
73+
return {"pagination": {"limit": 10, "offset": 0, "total": 2}, "ignored": ["one"]} # noqa: WPS226
74+
75+
@pytest.fixture
76+
def meta_data_multiple(self):
77+
return {"ignored": ["one", "two"]} # noqa: WPS226
78+
79+
@pytest.fixture
80+
def single_resource_data(self):
81+
return {"id": 1, "name": "test"}
82+
83+
@pytest.fixture
84+
def single_resource_response(self, single_resource_data, meta_data_single):
85+
return Response(200, json={"data": single_resource_data, "$meta": meta_data_single})
86+
87+
@pytest.fixture
88+
def multiple_resource_response(self, single_resource_data, meta_data_two_resources):
89+
return Response(
90+
200,
91+
json={
92+
"data": [single_resource_data, single_resource_data],
93+
"$meta": meta_data_two_resources,
94+
},
95+
)
96+
97+
def test_malformed_meta_response(self):
98+
with pytest.raises(TypeError, match=re.escape("Response $meta must be a dict.")):
99+
_resource = GenericResource.from_response(Response(200, json={"data": {}, "$meta": 4}))
100+
101+
def test_single_resource(self, single_resource_response):
102+
resource = GenericResource.from_response(single_resource_response)
103+
assert resource.id == 1
104+
assert resource.name == "test"
105+
assert isinstance(resource._meta, Meta)
106+
assert resource._meta.response == single_resource_response
107+
108+
def test_two_resources(self, multiple_resource_response, single_resource_data):
109+
with pytest.raises(TypeError, match=r"Response data must be a dict."):
110+
_resource = GenericResource.from_response(multiple_resource_response)

tests/http/models/test_meta.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import pytest
2+
from httpx import Response
3+
4+
from mpt_api_client.http.models import Meta, Pagination
5+
6+
7+
class TestMeta:
8+
9+
@pytest.fixture
10+
def responses_fixture(self):
11+
response_data = {
12+
"$meta": {
13+
"ignored": ["ignored"],
14+
"pagination": {"limit": 25, "offset": 50, "total": 300}
15+
16+
}
17+
}
18+
return Response(status_code=200, json=response_data)
19+
20+
@pytest.fixture
21+
def invalid_response_fixture(self):
22+
response_data = {
23+
"$meta": "invalid_meta"
24+
}
25+
return Response(status_code=200, json=response_data)
26+
27+
def test_meta_initialization_empty(self):
28+
meta = Meta()
29+
assert meta.pagination == Pagination(limit=0, offset=0, total=0)
30+
31+
def test_meta_from_response(self, responses_fixture):
32+
meta = Meta.from_response(responses_fixture)
33+
34+
assert isinstance(meta.pagination, Pagination)
35+
assert meta.pagination.limit == 25
36+
assert meta.pagination.offset == 50
37+
assert meta.pagination.total == 300
38+
39+
def test_invalid_meta_from_response(self, invalid_response_fixture):
40+
with pytest.raises(TypeError):
41+
Meta.from_response(invalid_response_fixture)
42+
43+
def test_meta_with_pagination_object(self):
44+
pagination = Pagination(limit=10, offset=0, total=100)
45+
meta = Meta(pagination=pagination)
46+
47+
assert meta.pagination == Pagination(limit=10, offset=0, total=100)

0 commit comments

Comments
 (0)