Skip to content

Commit c8ffdaf

Browse files
committed
MPT-12327 Implement collection model
1 parent 0907700 commit c8ffdaf

File tree

16 files changed

+369
-111
lines changed

16 files changed

+369
-111
lines changed

mpt_api_client/http/models.py

Lines changed: 0 additions & 100 deletions
This file was deleted.

mpt_api_client/models/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from mpt_api_client.models.collection import Collection
2+
from mpt_api_client.models.resource import Resource
3+
4+
__all__ = ["Collection", "Resource"] # noqa: WPS410
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from collections.abc import Iterator
2+
from typing import Any, ClassVar, Self
3+
4+
from httpx import Response
5+
6+
from mpt_api_client.models.meta import Meta
7+
from mpt_api_client.models.resource import Resource
8+
from mpt_api_client.models.types import BaseCollection, BaseResource, ResourceData
9+
10+
11+
class Collection[ResourceModel](BaseCollection):
12+
"""Provides a base collection to interact with api collection data using fluent interfaces."""
13+
14+
_data_key: ClassVar[str] = "data"
15+
_resource_model: ClassVar[type[BaseResource]] = Resource
16+
17+
def __init__(
18+
self, collection_data: list[ResourceData] | None = None, meta: Meta | None = None
19+
) -> None:
20+
self.meta = meta
21+
collection_data = collection_data or []
22+
self._resource_collection = [
23+
self._resource_model(resource_data, meta) for resource_data in collection_data
24+
]
25+
26+
def __getitem__(self, index: int) -> ResourceModel:
27+
"""Returns the collection item at the given index."""
28+
return self._resource_collection[index]
29+
30+
def __iter__(self) -> Iterator[ResourceModel]:
31+
"""Make GenericCollection iterable."""
32+
return iter(self._resource_collection)
33+
34+
def __len__(self) -> int:
35+
"""Return the number of items in the collection."""
36+
return len(self._resource_collection)
37+
38+
def __bool__(self) -> bool:
39+
"""Returns True if collection has items."""
40+
return len(self._resource_collection) > 0
41+
42+
@classmethod
43+
def from_response(cls, response: Response) -> Self:
44+
"""Creates a collection from a response."""
45+
response_data = response.json().get(cls._data_key)
46+
meta = Meta.from_response(response)
47+
if not isinstance(response_data, list):
48+
raise TypeError(f"Response `{cls._data_key}` must be a list for collection endpoints.")
49+
50+
return cls(response_data, meta)
51+
52+
def to_list(self) -> list[dict[str, Any]]:
53+
"""Returns the collection as a list of dictionaries."""
54+
return [resource.to_dict() for resource in self._resource_collection]

mpt_api_client/models/meta.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import math
2+
from dataclasses import dataclass, field
3+
from typing import Self
4+
5+
from httpx import Response
6+
7+
8+
@dataclass
9+
class Pagination:
10+
"""Provides pagination information."""
11+
12+
limit: int = 0
13+
offset: int = 0
14+
total: int = 0
15+
16+
def has_next(self) -> bool:
17+
"""Returns True if there is a next page."""
18+
return self.num_page() + 1 < self.total_pages()
19+
20+
def num_page(self) -> int:
21+
"""Returns the current page number starting the first page as 0."""
22+
if self.limit == 0:
23+
return 0
24+
return self.offset // self.limit
25+
26+
def total_pages(self) -> int:
27+
"""Returns the total number of pages."""
28+
if self.limit == 0:
29+
return 0
30+
return math.ceil(self.total / self.limit)
31+
32+
def next_offset(self) -> int:
33+
"""Returns the next offset as an integer for the next page."""
34+
return self.offset + self.limit
35+
36+
37+
@dataclass
38+
class Meta:
39+
"""Provides meta-information about the pagination, ignored fields and the response."""
40+
41+
response: Response
42+
pagination: Pagination = field(default_factory=Pagination)
43+
ignored: list[str] = field(default_factory=list)
44+
45+
@classmethod
46+
def from_response(cls, response: Response) -> Self:
47+
"""Creates a meta object from response."""
48+
meta_data = response.json().get("$meta", {})
49+
if not isinstance(meta_data, dict):
50+
raise TypeError("Response $meta must be a dict.")
51+
52+
return cls(
53+
ignored=meta_data.get("ignored", []),
54+
pagination=Pagination(**meta_data.get("pagination", {})),
55+
response=response,
56+
)

mpt_api_client/models/resource.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from typing import Any, ClassVar, Self, override
2+
3+
from box import Box
4+
from httpx import Response
5+
6+
from mpt_api_client.models.meta import Meta
7+
from mpt_api_client.models.types import BaseResource, ResourceData
8+
9+
10+
class Resource(BaseResource):
11+
"""Provides a resource to interact with api data using fluent interfaces."""
12+
13+
_data_key: ClassVar[str] = "data"
14+
_safe_attributes: ClassVar[list[str]] = ["meta", "_resource_data"]
15+
16+
def __init__(self, resource_data: ResourceData | None = None, meta: Meta | None = None) -> None:
17+
self.meta = meta
18+
self._resource_data = Box(resource_data or {}, camel_killer_box=True, default_box=False)
19+
20+
def __getattr__(self, attribute: str) -> Box | Any:
21+
"""Returns the resource data."""
22+
return self._resource_data.__getattr__(attribute) # type: ignore[no-untyped-call]
23+
24+
@override
25+
def __setattr__(self, attribute: str, attribute_value: Any) -> None:
26+
"""Sets the resource data."""
27+
if attribute in self._safe_attributes:
28+
object.__setattr__(self, attribute, attribute_value)
29+
return
30+
31+
self._resource_data.__setattr__(attribute, attribute_value) # type: ignore[no-untyped-call]
32+
33+
@classmethod
34+
def from_response(cls, response: Response) -> Self:
35+
"""Creates a Resource from a response."""
36+
response_data = response.json().get(cls._data_key)
37+
if not isinstance(response_data, dict):
38+
raise TypeError("Response data must be a dict.")
39+
meta = Meta.from_response(response)
40+
return cls(response_data, meta)
41+
42+
def to_dict(self) -> dict[str, Any]:
43+
"""Returns the resource as a dictionary."""
44+
return self._resource_data.to_dict()

mpt_api_client/models/types.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from abc import ABC, abstractmethod
2+
from typing import Any, Self, TypeVar
3+
4+
from httpx import Response
5+
6+
ResourceData = dict[str, Any]
7+
ResourceModel = TypeVar("ResourceModel", bound="BaseResource")
8+
9+
10+
class BaseResource(ABC):
11+
"""Provides a base resource to interact with api data using fluent interfaces."""
12+
13+
@classmethod
14+
@abstractmethod
15+
def from_response(cls, response: Response) -> Self:
16+
"""Creates a Resource from a response."""
17+
raise NotImplementedError
18+
19+
@abstractmethod
20+
def to_dict(self) -> dict[str, Any]:
21+
"""Returns the resource as a dictionary."""
22+
raise NotImplementedError
23+
24+
25+
class BaseCollection(ABC):
26+
"""Provides a base collection to interact with api collection data using fluent interfaces."""
27+
28+
@classmethod
29+
@abstractmethod
30+
def from_response(cls, response: Response) -> Self:
31+
"""Creates a collection from a response."""
32+
raise NotImplementedError
33+
34+
@abstractmethod
35+
def to_list(self) -> list[dict[str, Any]]:
36+
"""Returns the collection as a list of dictionaries."""
37+
raise NotImplementedError

setup.cfg

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ per-file-ignores =
3737
WPS110
3838
# Found `noqa` comments overuse
3939
WPS402
40-
4140
tests/*:
4241
# Allow magic strings
4342
WPS432
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import pytest
2+
3+
from mpt_api_client.models import Collection, Resource
4+
5+
6+
@pytest.fixture
7+
def meta_data():
8+
return {"pagination": {"limit": 10, "offset": 0, "total": 3}, "ignored": ["field1"]}
9+
10+
11+
@pytest.fixture
12+
def response_collection_data():
13+
return [
14+
{"id": 1, "user": {"name": "Alice", "surname": "Smith"}, "status": "active"},
15+
{"id": 2, "user": {"name": "Bob", "surname": "Johnson"}, "status": "inactive"},
16+
{"id": 3, "user": {"name": "Charlie", "surname": "Brown"}, "status": "active"},
17+
]
18+
19+
20+
TestCollection = Collection[Resource]
21+
22+
23+
@pytest.fixture
24+
def empty_collection() -> TestCollection:
25+
return TestCollection()
26+
27+
28+
@pytest.fixture
29+
def collection(response_collection_data):
30+
return TestCollection(response_collection_data)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from httpx import Response
2+
3+
from mpt_api_client.models.collection import Collection
4+
from mpt_api_client.models.resource import Resource
5+
6+
7+
class ChargeResourceMock(Collection[Resource]):
8+
_data_key = "charge"
9+
10+
11+
def charge(charge_id, amount) -> dict[str, int]:
12+
return {"id": charge_id, "amount": amount}
13+
14+
15+
def test_custom_data_key():
16+
payload = {"charge": [charge(1, 100), charge(2, 101)]}
17+
response = Response(200, json=payload)
18+
19+
resource = ChargeResourceMock.from_response(response)
20+
21+
assert resource[0].to_dict() == charge(1, 100)

0 commit comments

Comments
 (0)