Skip to content

Commit 50cf12c

Browse files
authored
MPT-12327 Implement generic Collection Result (#9)
2 parents 0907700 + 8563f8a commit 50cf12c

17 files changed

+393
-112
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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from mpt_api_client.models.collection import Collection
2+
from mpt_api_client.models.meta import Meta, Pagination
3+
from mpt_api_client.models.resource import Resource
4+
5+
__all__ = ["Collection", "Meta", "Pagination", "Resource"] # noqa: WPS410

mpt_api_client/models/base.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from abc import ABC, abstractmethod
2+
from typing import Any, Self
3+
4+
from httpx import Response
5+
6+
from mpt_api_client.models.meta import Meta
7+
8+
ResourceData = dict[str, Any]
9+
10+
11+
class BaseResource(ABC):
12+
"""Provides a base resource to interact with api data using fluent interfaces."""
13+
14+
@classmethod
15+
@abstractmethod
16+
def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None) -> Self:
17+
"""Creates a new resource from ResourceData and Meta."""
18+
raise NotImplementedError
19+
20+
@classmethod
21+
@abstractmethod
22+
def from_response(cls, response: Response) -> Self:
23+
"""Creates a collection from a response.
24+
25+
Args:
26+
response: The httpx response object.
27+
"""
28+
raise NotImplementedError
29+
30+
@abstractmethod
31+
def to_dict(self) -> dict[str, Any]:
32+
"""Returns the resource as a dictionary."""
33+
raise NotImplementedError
34+
35+
36+
class BaseCollection(ABC):
37+
"""Provides a base collection to interact with api collection data using fluent interfaces."""
38+
39+
@classmethod
40+
@abstractmethod
41+
def from_response(cls, response: Response) -> Self:
42+
"""Creates a collection from a response.
43+
44+
Args:
45+
response: The httpx response object.
46+
"""
47+
raise NotImplementedError
48+
49+
@abstractmethod
50+
def to_list(self) -> list[dict[str, Any]]:
51+
"""Returns the collection as a list of dictionaries."""
52+
raise NotImplementedError
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, override
3+
4+
from httpx import Response
5+
6+
from mpt_api_client.models.base import BaseCollection, ResourceData
7+
from mpt_api_client.models.meta import Meta
8+
from mpt_api_client.models.resource import Resource
9+
10+
11+
class Collection[ResourceType](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: type[Resource] = 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.new(resource_data, meta) for resource_data in collection_data
24+
]
25+
26+
def __getitem__(self, index: int) -> ResourceType:
27+
"""Returns the collection item at the given index."""
28+
return self._resource_collection[index] # type: ignore[return-value]
29+
30+
def __iter__(self) -> Iterator[ResourceType]:
31+
"""Make GenericCollection iterable."""
32+
return iter(self._resource_collection) # type: ignore[arg-type]
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+
@override
43+
@classmethod
44+
def from_response(cls, response: Response) -> Self:
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+
@override
53+
def to_list(self) -> list[dict[str, Any]]:
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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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.base import BaseResource, ResourceData
7+
from mpt_api_client.models.meta import Meta
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+
@classmethod
21+
@override
22+
def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None) -> Self:
23+
return cls(resource_data, meta)
24+
25+
def __getattr__(self, attribute: str) -> Box | Any:
26+
"""Returns the resource data."""
27+
return self._resource_data.__getattr__(attribute) # type: ignore[no-untyped-call]
28+
29+
@override
30+
def __setattr__(self, attribute: str, attribute_value: Any) -> None:
31+
if attribute in self._safe_attributes:
32+
object.__setattr__(self, attribute, attribute_value)
33+
return
34+
35+
self._resource_data.__setattr__(attribute, attribute_value) # type: ignore[no-untyped-call]
36+
37+
@classmethod
38+
@override
39+
def from_response(cls, response: Response) -> Self:
40+
response_data = response.json().get(cls._data_key)
41+
if not isinstance(response_data, dict):
42+
raise TypeError("Response data must be a dict.")
43+
meta = Meta.from_response(response)
44+
return cls.new(response_data, meta)
45+
46+
@override
47+
def to_dict(self) -> dict[str, Any]:
48+
return self._resource_data.to_dict()

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ source = ["mpt_api_client"]
7272
[tool.coverage.report]
7373
exclude_also = [
7474
"if __name__ == \"__main__\":",
75+
"raise NotImplementedError",
7576
]
7677
include = [
7778
"mpt_api_client/**",

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():
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)