diff --git a/mpt_api_client/http/models.py b/mpt_api_client/http/models.py deleted file mode 100644 index 7da2d664..00000000 --- a/mpt_api_client/http/models.py +++ /dev/null @@ -1,100 +0,0 @@ -import math -from dataclasses import dataclass, field -from typing import Any, ClassVar, Self, override - -from box import Box -from httpx import Response - - -@dataclass -class Pagination: - """Provides pagination information.""" - - limit: int = 0 - offset: int = 0 - total: int = 0 - - def has_next(self) -> bool: - """Returns True if there is a next page.""" - return self.num_page() + 1 < self.total_pages() - - def num_page(self) -> int: - """Returns the current page number starting the first page as 0.""" - if self.limit == 0: - return 0 - return self.offset // self.limit - - def total_pages(self) -> int: - """Returns the total number of pages.""" - if self.limit == 0: - return 0 - return math.ceil(self.total / self.limit) - - def next_offset(self) -> int: - """Returns the next offset as an integer for the next page.""" - return self.offset + self.limit - - -@dataclass -class Meta: - """Provides meta-information about the pagination, ignored fields and the response.""" - - response: Response - pagination: Pagination = field(default_factory=Pagination) - ignored: list[str] = field(default_factory=list) - - @classmethod - def from_response(cls, response: Response) -> Self: - """Creates a meta object from response.""" - meta_data = response.json().get("$meta", {}) - if not isinstance(meta_data, dict): - raise TypeError("Response $meta must be a dict.") - - return cls( - ignored=meta_data.get("ignored", []), - pagination=Pagination(**meta_data.get("pagination", {})), - response=response, - ) - - -ResourceData = dict[str, Any] - - -class GenericResource: - """Provides a base resource to interact with api data using fluent interfaces.""" - - _data_key: ClassVar[str] = "data" - _safe_attributes: ClassVar[list[str]] = ["meta", "_resource_data"] - - def __init__(self, resource_data: ResourceData | None = None, meta: Meta | None = None) -> None: - self.meta = meta - self._resource_data = Box(resource_data or {}, camel_killer_box=True, default_box=False) - - def __getattr__(self, attribute: str) -> Box | Any: - """Returns the resource data.""" - return self._resource_data.__getattr__(attribute) # type: ignore[no-untyped-call] - - @override - def __setattr__(self, attribute: str, attribute_value: Any) -> None: - """Sets the resource data.""" - if attribute in self._safe_attributes: - object.__setattr__(self, attribute, attribute_value) - return - - self._resource_data.__setattr__(attribute, attribute_value) # type: ignore[no-untyped-call] - - @classmethod - def from_response(cls, response: Response) -> Self: - """Creates a resource from a response. - - Expected a Response with json data with two keys: data and $meta. - """ - response_data = response.json().get(cls._data_key) - if not isinstance(response_data, dict): - raise TypeError("Response data must be a dict.") - meta = Meta.from_response(response) - return cls(response_data, meta) - - def to_dict(self) -> dict[str, Any]: - """Returns the resource as a dictionary.""" - return self._resource_data.to_dict() diff --git a/mpt_api_client/models/__init__.py b/mpt_api_client/models/__init__.py new file mode 100644 index 00000000..0e026579 --- /dev/null +++ b/mpt_api_client/models/__init__.py @@ -0,0 +1,5 @@ +from mpt_api_client.models.collection import Collection +from mpt_api_client.models.meta import Meta, Pagination +from mpt_api_client.models.resource import Resource + +__all__ = ["Collection", "Meta", "Pagination", "Resource"] # noqa: WPS410 diff --git a/mpt_api_client/models/base.py b/mpt_api_client/models/base.py new file mode 100644 index 00000000..af22be3d --- /dev/null +++ b/mpt_api_client/models/base.py @@ -0,0 +1,52 @@ +from abc import ABC, abstractmethod +from typing import Any, Self + +from httpx import Response + +from mpt_api_client.models.meta import Meta + +ResourceData = dict[str, Any] + + +class BaseResource(ABC): + """Provides a base resource to interact with api data using fluent interfaces.""" + + @classmethod + @abstractmethod + def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None) -> Self: + """Creates a new resource from ResourceData and Meta.""" + raise NotImplementedError + + @classmethod + @abstractmethod + def from_response(cls, response: Response) -> Self: + """Creates a collection from a response. + + Args: + response: The httpx response object. + """ + raise NotImplementedError + + @abstractmethod + def to_dict(self) -> dict[str, Any]: + """Returns the resource as a dictionary.""" + raise NotImplementedError + + +class BaseCollection(ABC): + """Provides a base collection to interact with api collection data using fluent interfaces.""" + + @classmethod + @abstractmethod + def from_response(cls, response: Response) -> Self: + """Creates a collection from a response. + + Args: + response: The httpx response object. + """ + raise NotImplementedError + + @abstractmethod + def to_list(self) -> list[dict[str, Any]]: + """Returns the collection as a list of dictionaries.""" + raise NotImplementedError diff --git a/mpt_api_client/models/collection.py b/mpt_api_client/models/collection.py new file mode 100644 index 00000000..1bb1d048 --- /dev/null +++ b/mpt_api_client/models/collection.py @@ -0,0 +1,54 @@ +from collections.abc import Iterator +from typing import Any, ClassVar, Self, override + +from httpx import Response + +from mpt_api_client.models.base import BaseCollection, ResourceData +from mpt_api_client.models.meta import Meta +from mpt_api_client.models.resource import Resource + + +class Collection[ResourceType](BaseCollection): + """Provides a base collection to interact with api collection data using fluent interfaces.""" + + _data_key: ClassVar[str] = "data" + _resource_model: type[Resource] = Resource + + def __init__( + self, collection_data: list[ResourceData] | None = None, meta: Meta | None = None + ) -> None: + self.meta = meta + collection_data = collection_data or [] + self._resource_collection = [ + self._resource_model.new(resource_data, meta) for resource_data in collection_data + ] + + def __getitem__(self, index: int) -> ResourceType: + """Returns the collection item at the given index.""" + return self._resource_collection[index] # type: ignore[return-value] + + def __iter__(self) -> Iterator[ResourceType]: + """Make GenericCollection iterable.""" + return iter(self._resource_collection) # type: ignore[arg-type] + + def __len__(self) -> int: + """Return the number of items in the collection.""" + return len(self._resource_collection) + + def __bool__(self) -> bool: + """Returns True if collection has items.""" + return len(self._resource_collection) > 0 + + @override + @classmethod + def from_response(cls, response: Response) -> Self: + response_data = response.json().get(cls._data_key) + meta = Meta.from_response(response) + if not isinstance(response_data, list): + raise TypeError(f"Response `{cls._data_key}` must be a list for collection endpoints.") + + return cls(response_data, meta) + + @override + def to_list(self) -> list[dict[str, Any]]: + return [resource.to_dict() for resource in self._resource_collection] diff --git a/mpt_api_client/models/meta.py b/mpt_api_client/models/meta.py new file mode 100644 index 00000000..e9bb2905 --- /dev/null +++ b/mpt_api_client/models/meta.py @@ -0,0 +1,56 @@ +import math +from dataclasses import dataclass, field +from typing import Self + +from httpx import Response + + +@dataclass +class Pagination: + """Provides pagination information.""" + + limit: int = 0 + offset: int = 0 + total: int = 0 + + def has_next(self) -> bool: + """Returns True if there is a next page.""" + return self.num_page() + 1 < self.total_pages() + + def num_page(self) -> int: + """Returns the current page number starting the first page as 0.""" + if self.limit == 0: + return 0 + return self.offset // self.limit + + def total_pages(self) -> int: + """Returns the total number of pages.""" + if self.limit == 0: + return 0 + return math.ceil(self.total / self.limit) + + def next_offset(self) -> int: + """Returns the next offset as an integer for the next page.""" + return self.offset + self.limit + + +@dataclass +class Meta: + """Provides meta-information about the pagination, ignored fields and the response.""" + + response: Response + pagination: Pagination = field(default_factory=Pagination) + ignored: list[str] = field(default_factory=list) + + @classmethod + def from_response(cls, response: Response) -> Self: + """Creates a meta object from response.""" + meta_data = response.json().get("$meta", {}) + if not isinstance(meta_data, dict): + raise TypeError("Response $meta must be a dict.") + + return cls( + ignored=meta_data.get("ignored", []), + pagination=Pagination(**meta_data.get("pagination", {})), + response=response, + ) diff --git a/mpt_api_client/models/resource.py b/mpt_api_client/models/resource.py new file mode 100644 index 00000000..6a7ea089 --- /dev/null +++ b/mpt_api_client/models/resource.py @@ -0,0 +1,48 @@ +from typing import Any, ClassVar, Self, override + +from box import Box +from httpx import Response + +from mpt_api_client.models.base import BaseResource, ResourceData +from mpt_api_client.models.meta import Meta + + +class Resource(BaseResource): + """Provides a resource to interact with api data using fluent interfaces.""" + + _data_key: ClassVar[str] = "data" + _safe_attributes: ClassVar[list[str]] = ["meta", "_resource_data"] + + def __init__(self, resource_data: ResourceData | None = None, meta: Meta | None = None) -> None: + self.meta = meta + self._resource_data = Box(resource_data or {}, camel_killer_box=True, default_box=False) + + @classmethod + @override + def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None) -> Self: + return cls(resource_data, meta) + + def __getattr__(self, attribute: str) -> Box | Any: + """Returns the resource data.""" + return self._resource_data.__getattr__(attribute) # type: ignore[no-untyped-call] + + @override + def __setattr__(self, attribute: str, attribute_value: Any) -> None: + if attribute in self._safe_attributes: + object.__setattr__(self, attribute, attribute_value) + return + + self._resource_data.__setattr__(attribute, attribute_value) # type: ignore[no-untyped-call] + + @classmethod + @override + def from_response(cls, response: Response) -> Self: + response_data = response.json().get(cls._data_key) + if not isinstance(response_data, dict): + raise TypeError("Response data must be a dict.") + meta = Meta.from_response(response) + return cls.new(response_data, meta) + + @override + def to_dict(self) -> dict[str, Any]: + return self._resource_data.to_dict() diff --git a/pyproject.toml b/pyproject.toml index 4c2b6917..07dba873 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,7 @@ source = ["mpt_api_client"] [tool.coverage.report] exclude_also = [ "if __name__ == \"__main__\":", + "raise NotImplementedError", ] include = [ "mpt_api_client/**", diff --git a/setup.cfg b/setup.cfg index 93343b2d..d3058d4f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,7 +37,6 @@ per-file-ignores = WPS110 # Found `noqa` comments overuse WPS402 - tests/*: # Allow magic strings WPS432 diff --git a/tests/models/collection/conftest.py b/tests/models/collection/conftest.py new file mode 100644 index 00000000..e2abbf61 --- /dev/null +++ b/tests/models/collection/conftest.py @@ -0,0 +1,30 @@ +import pytest + +from mpt_api_client.models import Collection, Resource + + +@pytest.fixture +def meta_data(): + return {"pagination": {"limit": 10, "offset": 0, "total": 3}, "ignored": ["field1"]} + + +@pytest.fixture +def response_collection_data(): + return [ + {"id": 1, "user": {"name": "Alice", "surname": "Smith"}, "status": "active"}, + {"id": 2, "user": {"name": "Bob", "surname": "Johnson"}, "status": "inactive"}, + {"id": 3, "user": {"name": "Charlie", "surname": "Brown"}, "status": "active"}, + ] + + +TestCollection = Collection[Resource] + + +@pytest.fixture +def empty_collection(): + return TestCollection() + + +@pytest.fixture +def collection(response_collection_data): + return TestCollection(response_collection_data) diff --git a/tests/models/collection/test_collection_custom_key.py b/tests/models/collection/test_collection_custom_key.py new file mode 100644 index 00000000..31aa2517 --- /dev/null +++ b/tests/models/collection/test_collection_custom_key.py @@ -0,0 +1,21 @@ +from httpx import Response + +from mpt_api_client.models.collection import Collection +from mpt_api_client.models.resource import Resource + + +class ChargeResourceMock(Collection[Resource]): + _data_key = "charge" + + +def charge(charge_id, amount) -> dict[str, int]: + return {"id": charge_id, "amount": amount} + + +def test_custom_data_key(): + payload = {"charge": [charge(1, 100), charge(2, 101)]} + response = Response(200, json=payload) + + resource = ChargeResourceMock.from_response(response) + + assert resource[0].to_dict() == charge(1, 100) diff --git a/tests/models/collection/test_collection_init.py b/tests/models/collection/test_collection_init.py new file mode 100644 index 00000000..8aecf8f3 --- /dev/null +++ b/tests/models/collection/test_collection_init.py @@ -0,0 +1,48 @@ +import pytest +from httpx import Response + +from mpt_api_client.models.collection import Collection +from mpt_api_client.models.meta import Meta +from tests.models.collection.conftest import TestCollection + + +def test_generic_collection_empty(empty_collection): + assert empty_collection.meta is None + assert len(empty_collection) == 0 + assert list(empty_collection) == [] + assert not empty_collection + + +def test_generic_collection_with_data(collection, response_collection_data): + assert len(collection) == 3 + assert bool(collection) is True + assert collection.to_list() == response_collection_data + + +def test_from_response(meta_data, response_collection_data): + response = Response(200, json={"data": response_collection_data, "$meta": meta_data}) + expected_meta = Meta.from_response(response) + + collection = TestCollection.from_response(response) + + assert collection.to_list() == response_collection_data + assert collection.meta == expected_meta + assert len(collection) == 3 + + +def test_wrong_data_type_from_response(): + response = Response(200, json={"data": {"not": "a list"}}) + + with pytest.raises( + TypeError, match=r"Response `data` must be a list for collection endpoints." + ): + Collection.from_response(response) + + +def test_collection_with_meta(meta_data, response_collection_data): + response = Response(200, json={"data": response_collection_data, "$meta": meta_data}) + meta = Meta.from_response(response) + + collection = TestCollection.from_response(response) + + assert collection.meta == meta diff --git a/tests/models/collection/test_collection_iteration.py b/tests/models/collection/test_collection_iteration.py new file mode 100644 index 00000000..0c6f7162 --- /dev/null +++ b/tests/models/collection/test_collection_iteration.py @@ -0,0 +1,22 @@ +import pytest + +from tests.models.collection.conftest import TestCollection + + +def test_iteration(collection): + resources = list(collection) + + assert len(resources) == 3 + + +def test_iteration_next(response_collection_data): + collection = TestCollection(response_collection_data) + + iterator = iter(collection) + assert next(iterator).id == response_collection_data[0]["id"] + assert next(iterator).id == response_collection_data[1]["id"] + assert next(iterator).id == response_collection_data[2]["id"] + + # Check that iterator is exhausted + with pytest.raises(StopIteration): + next(iterator) diff --git a/tests/models/collection/test_collection_list.py b/tests/models/collection/test_collection_list.py new file mode 100644 index 00000000..2cd715b4 --- /dev/null +++ b/tests/models/collection/test_collection_list.py @@ -0,0 +1,45 @@ +import pytest + +from tests.models.collection.conftest import TestCollection + + +def test_getitem_access(response_collection_data): + collection = TestCollection(response_collection_data) + + assert collection[0].to_dict() == response_collection_data[0] + assert collection[1].to_dict() == response_collection_data[1] + assert collection[2].to_dict() == response_collection_data[2] + + +def test_getitem_out_of_bounds(collection): + with pytest.raises(IndexError): + collection[10] + + +def test_length(empty_collection, response_collection_data): + collection = TestCollection(response_collection_data) + + assert len(empty_collection) == 0 + assert len(collection) == 3 + + +def test_bool_conversion(empty_collection, response_collection_data): + collection_with_data = TestCollection(response_collection_data) + + assert bool(empty_collection) is False + assert bool(collection_with_data) is True + + +def test_to_list_method(response_collection_data): + collection = TestCollection(response_collection_data) + resources = collection.to_list() + + assert resources == response_collection_data + assert isinstance(resources, list) + + +def test_empty_collection_to_list(empty_collection): + resources = empty_collection.to_list() + + assert resources == [] + assert isinstance(resources, list) diff --git a/tests/http/models/test_meta.py b/tests/models/meta/test_meta.py similarity index 93% rename from tests/http/models/test_meta.py rename to tests/models/meta/test_meta.py index c558ff3a..0e8c2aad 100644 --- a/tests/http/models/test_meta.py +++ b/tests/models/meta/test_meta.py @@ -1,7 +1,7 @@ import pytest from httpx import Response -from mpt_api_client.http.models import Meta, Pagination +from mpt_api_client.models import Meta, Pagination @pytest.fixture diff --git a/tests/http/models/test_pagination.py b/tests/models/meta/test_pagination.py similarity index 97% rename from tests/http/models/test_pagination.py rename to tests/models/meta/test_pagination.py index 67db71d4..cf670e2a 100644 --- a/tests/http/models/test_pagination.py +++ b/tests/models/meta/test_pagination.py @@ -1,6 +1,6 @@ import pytest -from mpt_api_client.http.models import Pagination +from mpt_api_client.models import Pagination def test_default_page(): # noqa: WPS218 diff --git a/tests/http/models/test_generic_resource.py b/tests/models/resource/test_resource.py similarity index 77% rename from tests/http/models/test_generic_resource.py rename to tests/models/resource/test_resource.py index 1754c51d..a6d08337 100644 --- a/tests/http/models/test_generic_resource.py +++ b/tests/models/resource/test_resource.py @@ -1,7 +1,7 @@ import pytest from httpx import Response -from mpt_api_client.http.models import GenericResource, Meta +from mpt_api_client.models import Meta, Resource @pytest.fixture @@ -9,8 +9,8 @@ def meta_data(): return {"pagination": {"limit": 10, "offset": 20, "total": 100}, "ignored": ["one"]} # noqa: WPS226 -def test_generic_resource_empty(): - resource = GenericResource() +def test_resource_empty(): + resource = Resource() assert resource.meta is None assert resource.to_dict() == {} @@ -21,7 +21,7 @@ def test_from_response(meta_data): response = Response(200, json={"data": record_data, "$meta": meta_data}) expected_meta = Meta.from_response(response) - resource = GenericResource.from_response(response) + resource = Resource.from_response(response) assert resource.to_dict() == record_data assert resource.meta == expected_meta @@ -31,7 +31,7 @@ def test_attribute_getter(mocker, meta_data): resource_data = {"id": 1, "name": {"given": "Albert", "family": "Einstein"}} response = Response(200, json={"data": resource_data, "$meta": meta_data}) - resource = GenericResource.from_response(response) + resource = Resource.from_response(response) assert resource.id == 1 assert resource.name.given == "Albert" @@ -39,7 +39,7 @@ def test_attribute_getter(mocker, meta_data): def test_attribute_setter(): resource_data = {"id": 1, "name": {"given": "Albert", "family": "Einstein"}} - resource = GenericResource(resource_data) + resource = Resource(resource_data) resource.id = 2 resource.name.given = "John" @@ -50,4 +50,4 @@ def test_attribute_setter(): def test_wrong_data_type(): with pytest.raises(TypeError, match=r"Response data must be a dict."): - GenericResource.from_response(Response(200, json={"data": 1})) + Resource.from_response(Response(200, json={"data": 1})) diff --git a/tests/http/models/test_generic_resource_custom_key.py b/tests/models/resource/test_resource_custom_key.py similarity index 75% rename from tests/http/models/test_generic_resource_custom_key.py rename to tests/models/resource/test_resource_custom_key.py index 9da662f8..25a80cb3 100644 --- a/tests/http/models/test_generic_resource_custom_key.py +++ b/tests/models/resource/test_resource_custom_key.py @@ -1,9 +1,9 @@ from httpx import Response -from mpt_api_client.http.models import GenericResource +from mpt_api_client.models import Resource -class ChargeResourceMock(GenericResource): +class ChargeResourceMock(Resource): _data_key = "charge"