Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions mpt_api_client/http/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import math
from dataclasses import dataclass, field
from typing import Any, Self

from box import Box
from httpx import Response


@dataclass
class Pagination:
"""Provides pagination information."""

Comment thread
svazquezco marked this conversation as resolved.
limit: int = 0
offset: int = 0
total: int = 0

def has_next(self) -> bool:
"""Returns True if there is a next page."""
return self.offset + self.limit < self.total

def num_page(self) -> int:
"""Returns the current page number."""
if self.limit == 0:
return 0
return (self.offset // self.limit) + 1

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
Comment thread
svazquezco marked this conversation as resolved.


@dataclass
class Meta:
"""Provides meta information about the pagination, ignored fields and the response."""

pagination: Pagination = field(default_factory=Pagination)
ignored: list[str] = field(default_factory=list)
response: Response | None = None
Comment thread
albertsola marked this conversation as resolved.
Outdated

@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,
)


class GenericResource(Box):
"""Provides a base resource to interact with api data using fluent interfaces."""

Comment thread
albertsola marked this conversation as resolved.
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.__post_init__()

def __post_init__(self) -> None:
"""Initializes meta information."""
meta = self.get("$meta", None) # type: ignore[no-untyped-call]
Comment thread
albertsola marked this conversation as resolved.
Outdated
if meta:
self._meta = Meta(**meta)
Comment thread
albertsola marked this conversation as resolved.
Outdated

@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("data")
if not isinstance(response_data, dict):
raise TypeError("Response data must be a dict.")
meta = Meta.from_response(response)
meta.response = response
resource = cls(response_data)
resource._meta = meta
Comment thread
albertsola marked this conversation as resolved.
Outdated
return resource
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ classifiers = [
"Topic :: Utilities",
]
dependencies = [
"httpx==0.28.*"
"httpx==0.28.*",
"python-box>=7.3.2",
]

[dependency-groups]
Expand Down Expand Up @@ -166,13 +167,16 @@ pydocstyle.convention = "google"

[tool.ruff.lint.per-file-ignores]
"tests/*.py" = [
"D101", # do not require docstrings in public classes
"D102", # do not require docstrincs in public method
"D103", # missing docstring in public function
"PLR2004", # allow magic numbers in tests
"S101", # asserts
"S105", # hardcoded passwords
"S404", # subprocess calls are for tests
"S603", # do not require `shell=True`
"S607", # partial executable paths
"SLF001", # Allow private property/method access
Comment thread
albertsola marked this conversation as resolved.
Outdated
]

[tool.mypy]
Expand Down
19 changes: 18 additions & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,21 @@ extend-exclude =
select = WPS, E999

per-file-ignores =
tests/*: WPS432
tests/*:
Comment thread
albertsola marked this conversation as resolved.
# Allow private property/method access
SLF001

# Allow unused variables
WPS122

# Allow >7 methods
WPS214

# Allow string literal overuse
WPS226

# Allow magic strings
WPS432

# Allow noqa overuse
WPS402
Comment thread
albertsola marked this conversation as resolved.
Outdated
110 changes: 110 additions & 0 deletions tests/http/models/test_genric_resource.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import re

import pytest
from httpx import Response

from mpt_api_client.http.models import GenericResource, Meta


@pytest.fixture
def meta_data():
return {"pagination": {"limit": 10, "offset": 20, "total": 100}, "ignored": ["one"]} # noqa: WPS226


class TestGenericResource: # noqa: WPS214
Comment thread
albertsola marked this conversation as resolved.
Outdated
def test_generic_resource_empty(self):
resource = GenericResource()
with pytest.raises(AttributeError):
_ = resource._meta
Comment thread
albertsola marked this conversation as resolved.
Outdated

def test_initialization_with_data(self):
resource = GenericResource(name="test", value=123)

assert resource.name == "test"
assert resource.value == 123

def test_init(self, meta_data):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do you need to check the dataclass features?

resource = {"$meta": meta_data, "key": "value"} # noqa: WPS445 WPS517
init_one = GenericResource(resource)
init_two = GenericResource(**resource)
assert init_one == init_two

def test_generic_resource_meta_property_with_data(self, meta_data):
resource = GenericResource({"$meta": meta_data})
assert resource._meta == Meta(**meta_data)

def test_generic_resource_box_functionality(self):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really I didn't get why we should check here library functionality?

resource = GenericResource(id=1, name="test_resource", nested={"key": "value"})

assert resource.id == 1
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three separate test cases, should be done either with 3 different tests or with parametrized

assert resource.name == "test_resource"
assert resource.nested.key == "value"

def test_with_both_meta_and_response(self, meta_data):
response = Response(200, json={})
meta_data["response"] = response
meta_object = Meta(**meta_data)

resource = GenericResource(
data="test_data",
**{"$meta": meta_data}, # noqa: WPS445 WPS517
)

assert resource.data == "test_data"
assert resource._meta == meta_object

def test_dynamic_attribute_access(self):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, that's library functionality :-)

resource = GenericResource()

resource.dynamic_field = "dynamic_value"
resource.nested_object = {"inner": "data"}

assert resource.dynamic_field == "dynamic_value"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two separate test cases

assert resource.nested_object.inner == "data"
Comment thread
albertsola marked this conversation as resolved.
Outdated


class TestGenericResourceFromResponse:
@pytest.fixture
def meta_data_single(self):
return {"ignored": ["one"]} # noqa: WPS226

@pytest.fixture
def meta_data_two_resources(self):
return {"pagination": {"limit": 10, "offset": 0, "total": 2}, "ignored": ["one"]} # noqa: WPS226

@pytest.fixture
def meta_data_multiple(self):
return {"ignored": ["one", "two"]} # noqa: WPS226

@pytest.fixture
def single_resource_data(self):
return {"id": 1, "name": "test"}

@pytest.fixture
def single_resource_response(self, single_resource_data, meta_data_single):
return Response(200, json={"data": single_resource_data, "$meta": meta_data_single})

@pytest.fixture
def multiple_resource_response(self, single_resource_data, meta_data_two_resources):
return Response(
200,
json={
"data": [single_resource_data, single_resource_data],
"$meta": meta_data_two_resources,
},
)

def test_malformed_meta_response(self):
with pytest.raises(TypeError, match=re.escape("Response $meta must be a dict.")):
_resource = GenericResource.from_response(Response(200, json={"data": {}, "$meta": 4}))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just

GenericResource.from_response(Response(200, json={"data": {}, "$meta": 4}))


def test_single_resource(self, single_resource_response):
resource = GenericResource.from_response(single_resource_response)
assert resource.id == 1
assert resource.name == "test"
assert isinstance(resource._meta, Meta)
assert resource._meta.response == single_resource_response

def test_two_resources(self, multiple_resource_response, single_resource_data):
with pytest.raises(TypeError, match=r"Response data must be a dict."):
_resource = GenericResource.from_response(multiple_resource_response)
Comment thread
albertsola marked this conversation as resolved.
Outdated
Comment thread
albertsola marked this conversation as resolved.
Outdated
47 changes: 47 additions & 0 deletions tests/http/models/test_meta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import pytest
from httpx import Response

from mpt_api_client.http.models import Meta, Pagination


class TestMeta:

@pytest.fixture
def responses_fixture(self):
response_data = {
"$meta": {
"ignored": ["ignored"],
"pagination": {"limit": 25, "offset": 50, "total": 300}

}
}
return Response(status_code=200, json=response_data)

@pytest.fixture
def invalid_response_fixture(self):
response_data = {
"$meta": "invalid_meta"
}
return Response(status_code=200, json=response_data)

def test_meta_initialization_empty(self):
meta = Meta()
assert meta.pagination == Pagination(limit=0, offset=0, total=0)

def test_meta_from_response(self, responses_fixture):
meta = Meta.from_response(responses_fixture)

assert isinstance(meta.pagination, Pagination)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not?

assert meta.pagination == Pagination(limit=25, offset=50, total=300)

assert meta.pagination.limit == 25
assert meta.pagination.offset == 50
assert meta.pagination.total == 300

def test_invalid_meta_from_response(self, invalid_response_fixture):
with pytest.raises(TypeError):
Comment thread
albertsola marked this conversation as resolved.
Outdated
Meta.from_response(invalid_response_fixture)

def test_meta_with_pagination_object(self):
pagination = Pagination(limit=10, offset=0, total=100)
meta = Meta(pagination=pagination)

assert meta.pagination == Pagination(limit=10, offset=0, total=100)
Comment thread
albertsola marked this conversation as resolved.
Outdated
Loading