Skip to content

Commit b3dccea

Browse files
committed
Updated generic resource and tests
1 parent 546f0db commit b3dccea

File tree

5 files changed

+199
-242
lines changed

5 files changed

+199
-242
lines changed

mpt_api_client/http/models.py

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import math
22
from dataclasses import dataclass, field
3-
from typing import Any, Self
3+
from typing import Any, ClassVar, Self, override
44

55
from box import Box
66
from httpx import Response
@@ -16,13 +16,15 @@ class Pagination:
1616

1717
def has_next(self) -> bool:
1818
"""Returns True if there is a next page."""
19+
if self.limit == 0:
20+
return False
1921
return self.offset + self.limit < self.total
2022

2123
def num_page(self) -> int:
2224
"""Returns the current page number."""
2325
if self.limit == 0:
2426
return 0
25-
return (self.offset // self.limit) + 1
27+
return self.offset // self.limit
2628

2729
def total_pages(self) -> int:
2830
"""Returns the total number of pages."""
@@ -37,16 +39,16 @@ def next_offset(self) -> int:
3739

3840
@dataclass
3941
class Meta:
40-
"""Provides meta information about the pagination, ignored fields and the response."""
42+
"""Provides meta-information about the pagination, ignored fields and the response."""
4143

44+
response: Response
4245
pagination: Pagination = field(default_factory=Pagination)
4346
ignored: list[str] = field(default_factory=list)
44-
response: Response | None = None
4547

4648
@classmethod
4749
def from_response(cls, response: Response) -> Self:
4850
"""Creates a meta object from response."""
49-
meta_data = response.json().get("$meta")
51+
meta_data = response.json().get("$meta", {})
5052
if not isinstance(meta_data, dict):
5153
raise TypeError("Response $meta must be a dict.")
5254

@@ -57,30 +59,45 @@ def from_response(cls, response: Response) -> Self:
5759
)
5860

5961

60-
class GenericResource(Box):
62+
ResourceType = dict[str, Any] | None
63+
64+
65+
class GenericResource:
6166
"""Provides a base resource to interact with api data using fluent interfaces."""
6267

63-
def __init__(self, *args: Any, **kwargs: Any) -> None:
64-
super().__init__(*args, **kwargs)
65-
self.__post_init__()
68+
_data_key: ClassVar[str] = "data"
69+
_safe_attributes: ClassVar[list[str]] = ["meta", "_resource"]
70+
71+
def __init__(self, resource: ResourceType = None, meta: Meta | None = None) -> None:
72+
resource = resource or {}
73+
self.meta: Meta | None = meta
74+
self._resource: Box = Box(resource, camel_killer_box=True, default_box=False)
75+
76+
def __getattr__(self, attribute: str) -> Box | Any:
77+
"""Returns the resource data."""
78+
return self._resource.__getattr__(attribute) # type: ignore[no-untyped-call]
79+
80+
@override
81+
def __setattr__(self, attribute: str, attribute_value: Any) -> None:
82+
"""Sets the resource data."""
83+
if attribute in self._safe_attributes:
84+
object.__setattr__(self, attribute, attribute_value)
85+
return
86+
87+
self._resource.__setattr__(attribute, attribute_value) # type: ignore[no-untyped-call]
6688

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)
89+
def to_dict(self) -> dict[str, Any]:
90+
"""Returns the resource as a dictionary."""
91+
return self._resource.to_dict()
7292

7393
@classmethod
7494
def from_response(cls, response: Response) -> Self:
7595
"""Creates a resource from a response.
7696
7797
Expected a Response with json data with two keys: data and $meta.
7898
"""
79-
response_data = response.json().get("data")
99+
response_data = response.json().get(cls._data_key)
80100
if not isinstance(response_data, dict):
81101
raise TypeError("Response data must be a dict.")
82102
meta = Meta.from_response(response)
83-
meta.response = response
84-
resource = cls(response_data)
85-
resource._meta = meta
86-
return resource
103+
return cls(response_data, meta)
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: 33 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import re
2-
31
import pytest
42
from httpx import Response
53

@@ -11,100 +9,53 @@ def meta_data():
119
return {"pagination": {"limit": 10, "offset": 20, "total": 100}, "ignored": ["one"]} # noqa: WPS226
1210

1311

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"})
12+
def test_generic_resource_empty():
13+
resource = GenericResource()
14+
assert resource.meta is None
15+
assert resource.to_dict() == {}
3816

39-
assert resource.id == 1
40-
assert resource.name == "test_resource"
41-
assert resource.nested.key == "value"
4217

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)
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)
4722

48-
resource = GenericResource(
49-
data="test_data",
50-
**{"$meta": meta_data}, # noqa: WPS445 WPS517
51-
)
23+
resource = GenericResource.from_response(response)
5224

53-
assert resource.data == "test_data"
54-
assert resource._meta == meta_object
25+
assert resource.to_dict() == record_data
26+
assert resource.meta == expected_meta
5527

56-
def test_dynamic_attribute_access(self):
57-
resource = GenericResource()
5828

59-
resource.dynamic_field = "dynamic_value"
60-
resource.nested_object = {"inner": "data"}
29+
def test_attribute_access():
30+
record_data = {"id": 1, "name": {"given": "Albert", "family": "Einstein"}}
31+
meta = Meta.from_response(Response(200, json={"$meta": {}}))
32+
resource = GenericResource(resource=record_data, meta=meta)
6133

62-
assert resource.dynamic_field == "dynamic_value"
63-
assert resource.nested_object.inner == "data"
34+
assert resource.meta == meta
6435

36+
assert resource.id == 1
6537

66-
class TestGenericResourceFromResponse:
67-
@pytest.fixture
68-
def meta_data_single(self):
69-
return {"ignored": ["one"]} # noqa: WPS226
38+
with pytest.raises(AttributeError, match=r"'Box' object has no attribute 'address'"):
39+
resource.address # noqa: B018
7040

71-
@pytest.fixture
72-
def meta_data_two_resources(self):
73-
return {"pagination": {"limit": 10, "offset": 0, "total": 2}, "ignored": ["one"]} # noqa: WPS226
41+
with pytest.raises(AttributeError, match=r"'Box' object has no attribute 'surname'"):
42+
resource.name.surname # noqa: B018
7443

75-
@pytest.fixture
76-
def meta_data_multiple(self):
77-
return {"ignored": ["one", "two"]} # noqa: WPS226
44+
assert resource.name.given == "Albert"
45+
assert resource.name.to_dict() == record_data["name"]
7846

79-
@pytest.fixture
80-
def single_resource_data(self):
81-
return {"id": 1, "name": "test"}
8247

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})
48+
def test_attribute_setter():
49+
record_data = {"id": 1, "name": {"given": "Albert", "family": "Einstein"}}
50+
resource = GenericResource(resource=record_data)
8651

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-
)
52+
resource.id = 2
53+
assert resource.id == 2
9654

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}))
55+
resource.name.given = "John"
56+
assert resource.name.given == "John"
10057

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
10758

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)
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: 26 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,44 +4,38 @@
44
from mpt_api_client.http.models import Meta, Pagination
55

66

7-
class TestMeta:
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)
816

9-
@pytest.fixture
10-
def responses_fixture(self):
11-
response_data = {
12-
"$meta": {
13-
"ignored": ["ignored"],
14-
"pagination": {"limit": 25, "offset": 50, "total": 300}
1517

16-
}
17-
}
18-
return Response(status_code=200, json=response_data)
18+
@pytest.fixture
19+
def invalid_response_fixture():
20+
response_data = {"$meta": "invalid_meta"}
21+
return Response(status_code=200, json=response_data)
1922

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)
2623

27-
def test_meta_initialization_empty(self):
28-
meta = Meta()
29-
assert meta.pagination == Pagination(limit=0, offset=0, total=0)
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)
3029

31-
def test_meta_from_response(self, responses_fixture):
32-
meta = Meta.from_response(responses_fixture)
3330

34-
assert isinstance(meta.pagination, Pagination)
35-
assert meta.pagination.limit == 25
36-
assert meta.pagination.offset == 50
37-
assert meta.pagination.total == 300
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)
3834

39-
def test_invalid_meta_from_response(self, invalid_response_fixture):
40-
with pytest.raises(TypeError):
41-
Meta.from_response(invalid_response_fixture)
4235

43-
def test_meta_with_pagination_object(self):
44-
pagination = Pagination(limit=10, offset=0, total=100)
45-
meta = Meta(pagination=pagination)
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)
4640

47-
assert meta.pagination == Pagination(limit=10, offset=0, total=100)
41+
assert meta.pagination == Pagination(limit=10, offset=0, total=100)

0 commit comments

Comments
 (0)