Skip to content

Commit b4fb949

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

File tree

7 files changed

+416
-3
lines changed

7 files changed

+416
-3
lines changed

mpt_api_client/http/models.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import math
2+
from dataclasses import dataclass, field
3+
from typing import Any, Self, TypeVar
4+
5+
from box import Box
6+
from httpx import Response
7+
8+
ExpectedType = TypeVar("ExpectedType")
9+
10+
11+
def ensure_class_instance(
12+
instance: dict[str, Any] | ExpectedType | None, expected_class: type[ExpectedType]
13+
) -> ExpectedType | None:
14+
"""
15+
Ensures that the given value is an instance of the specified class.
16+
17+
If `value` is a dict, it instantiates `cls` with the dict as keyword arguments.
18+
If `value` is already an instance of `cls` or None, it returns `value` as is.
19+
20+
Args:
21+
instance: None, a dict, or an instance of `cls`.
22+
expected_class: The class to instantiate if `value` is a dict.
23+
24+
Returns:
25+
An instance of `cls` or None.
26+
"""
27+
if isinstance(instance, dict):
28+
return expected_class(**instance)
29+
return instance
30+
31+
32+
@dataclass
33+
class Pagination:
34+
"""Provides pagination information."""
35+
36+
limit: int = 0
37+
offset: int = 0
38+
total: int = 0
39+
40+
def has_next(self) -> bool:
41+
"""Returns True if there is a next page."""
42+
return self.offset + self.limit < self.total
43+
44+
def num_page(self) -> int:
45+
"""Returns the current page number."""
46+
if self.limit == 0:
47+
return 0
48+
return (self.offset // self.limit) + 1
49+
50+
def total_pages(self) -> int:
51+
"""Returns the total number of pages."""
52+
if self.limit == 0:
53+
return 0
54+
return math.ceil(self.total / self.limit)
55+
56+
def next_offset(self) -> int:
57+
"""Returns the next offset as an integer for the next page."""
58+
return self.offset + self.limit
59+
60+
61+
@dataclass
62+
class Meta:
63+
"""Provides meta information about the pagination, ignored fields and the response."""
64+
65+
pagination: Pagination = field(default_factory=Pagination)
66+
ignored: list[str] = field(default_factory=list)
67+
response: Response | None = None
68+
69+
def __post_init__(self) -> None:
70+
"""Setups pagination and ignored fields."""
71+
self.pagination = ensure_class_instance(self.pagination, Pagination) or Pagination()
72+
self.ignored = self.ignored or []
73+
74+
75+
class GenericResource(Box):
76+
"""Provides a base resource to interact with api data using fluent interfaces."""
77+
78+
def __init__(self, *args: Any, **kwargs: Any) -> None:
79+
super().__init__(*args, **kwargs)
80+
self.__post_init__()
81+
82+
def __post_init__(self) -> None:
83+
"""Initializes meta information."""
84+
meta = self.get("$meta", None) # type: ignore[no-untyped-call]
85+
if meta:
86+
self._meta = Meta(**meta)
87+
88+
@classmethod
89+
def from_response(cls, response: Response) -> Self:
90+
"""Creates a resource from a response.
91+
92+
Expected a Response with json data with two keys: data and $meta.
93+
"""
94+
response_data = response.json().get("data")
95+
if not isinstance(response_data, dict):
96+
raise TypeError("Response data must be a dict.")
97+
98+
meta_data = response.json().get("$meta")
99+
if not isinstance(meta_data, dict):
100+
raise TypeError("Response $meta must be a dict.")
101+
meta = Meta(**meta_data)
102+
meta.response = response
103+
resource = cls(response_data)
104+
resource._meta = meta
105+
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: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from mpt_api_client.http.models import Meta, Pagination
2+
3+
4+
class TestMeta:
5+
def test_meta_initialization_empty(self):
6+
meta = Meta()
7+
8+
assert meta.pagination == Pagination(limit=0, offset=0, total=0)
9+
10+
def test_meta_initialization_with_pagination_none(self):
11+
meta = Meta(pagination=None)
12+
13+
assert meta.pagination == Pagination(limit=0, offset=0, total=0)
14+
15+
def test_meta_with_pagination_object(self):
16+
pagination = Pagination(limit=10, offset=0, total=100)
17+
meta = Meta(pagination=pagination)
18+
19+
assert meta.pagination == Pagination(limit=10, offset=0, total=100)
20+
21+
def test_meta_with_pagination_dict(self):
22+
meta = Meta(pagination={"limit": 20, "offset": 40, "total": 200})
23+
24+
assert isinstance(meta.pagination, Pagination)
25+
assert meta.pagination.limit == 20
26+
assert meta.pagination.offset == 40
27+
assert meta.pagination.total == 200
28+
29+
def test_meta_set_pagination_after_init(self):
30+
meta = Meta()
31+
pagination = Pagination(limit=15, offset=30, total=150)
32+
33+
meta.pagination = pagination
34+
35+
assert meta.pagination == pagination
36+
37+
def test_meta_access_pagination_methods(self):
38+
resource_data = {"pagination": {"limit": 25, "offset": 50, "total": 300}}
39+
meta = Meta(**resource_data)
40+
assert isinstance(meta.pagination, Pagination)
41+
assert meta.pagination.has_next() is True
42+
assert meta.pagination.num_page() == 3
43+
assert meta.pagination.total_pages() == 12
44+
assert meta.pagination.next_offset() == 75

0 commit comments

Comments
 (0)