Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
77 changes: 70 additions & 7 deletions mpt_api_client/models/model.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,85 @@
from typing import Any, ClassVar, Self, override

from box import Box
from box.box import _camel_killer # type: ignore[attr-defined] # noqa: PLC2701

from mpt_api_client.http.types import Response
from mpt_api_client.models.meta import Meta

ResourceData = dict[str, Any]

_box_safe_attributes: list[str] = ["_box_config", "_attribute_mapping"]

class Model:

class MptBox(Box):
"""python-box that preserves camelCase keys when converted to json."""

def __init__(self, *args, attribute_mapping: dict[str, str] | None = None, **_): # type: ignore[no-untyped-def]
attribute_mapping = attribute_mapping or {}
self._attribute_mapping = attribute_mapping
super().__init__(
*args,
camel_killer_box=False,
default_box=False,
default_box_create_on_get=False,
)

@override
def __setitem__(self, key, value): # type: ignore[no-untyped-def]
mapped_key = self._prep_key(key)
super().__setitem__(mapped_key, value) # type: ignore[no-untyped-call]

@override
def __setattr__(self, item: str, value: Any) -> None:
if item in _box_safe_attributes:
return object.__setattr__(self, item, value)

super().__setattr__(item, value) # type: ignore[no-untyped-call]
return None

@override
def __getattr__(self, item: str) -> Any:
if item in _box_safe_attributes:
return object.__getattribute__(self, item)
return super().__getattr__(item) # type: ignore[no-untyped-call]

@override
def to_dict(self) -> dict[str, Any]: # noqa: WPS210
reverse_mapping = {
mapped_key: original_key for original_key, mapped_key in self._attribute_mapping.items()
}
out_dict = {}
for parsed_key, item_value in super().to_dict().items():
original_key = reverse_mapping[parsed_key]
out_dict[original_key] = item_value
return out_dict

def _prep_key(self, key: str) -> str:
try:
return self._attribute_mapping[key]
except KeyError:
self._attribute_mapping[key] = _camel_killer(key)
return self._attribute_mapping[key]


class Model: # noqa: WPS214
"""Provides a resource to interact with api data using fluent interfaces."""

_data_key: ClassVar[str | None] = None
_safe_attributes: ClassVar[list[str]] = ["meta", "_resource_data"]
_safe_attributes: ClassVar[list[str]] = ["meta", "_box"]
_attribute_mapping: ClassVar[dict[str, str]] = {}

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)
self._box = MptBox(
resource_data or {},
attribute_mapping=self._attribute_mapping,
)

@override
def __repr__(self) -> str:
class_name = self.__class__.__name__
return f"<{class_name} {self.id}>"

@classmethod
def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None) -> Self:
Expand All @@ -25,15 +88,15 @@ def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None

def __getattr__(self, attribute: str) -> Box | Any:
"""Returns the resource data."""
return self._resource_data.__getattr__(attribute) # type: ignore[no-untyped-call]
return self._box.__getattr__(attribute)

@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]
self._box.__setattr__(attribute, attribute_value)

@classmethod
def from_response(cls, response: Response) -> Self:
Expand All @@ -55,8 +118,8 @@ def from_response(cls, response: Response) -> Self:
@property
def id(self) -> str:
"""Returns the resource ID."""
return str(self._resource_data.get("id", "")) # type: ignore[no-untyped-call]
return str(self._box.get("id", "")) # type: ignore[no-untyped-call]

def to_dict(self) -> dict[str, Any]:
"""Returns the resource as a dictionary."""
return self._resource_data.to_dict()
return self._box.to_dict()
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ per-file-ignores =
mpt_api_client/mpt_client.py: WPS214 WPS235
mpt_api_client/http/mixins.py: WPS202
mpt_api_client/resources/*: WPS215
mpt_api_client/models/model.py: WPS215 WPS110
mpt_api_client/resources/accounts/*.py: WPS202 WPS215 WPS214
mpt_api_client/resources/billing/*.py: WPS202 WPS204 WPS214 WPS215
mpt_api_client/resources/catalog/*.py: WPS110 WPS214 WPS215
Expand Down
59 changes: 59 additions & 0 deletions tests/unit/models/resource/test_resource.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import ClassVar

import pytest
from httpx import Response

Expand Down Expand Up @@ -70,3 +72,60 @@ def test_id_property_with_numeric_id():

assert resource.id == "1024"
assert isinstance(resource.id, str)


def test_case_conversion():
resource_data = {"id": "abc-123", "FullName": "Alice Smith"}

resource = Model(resource_data)

assert resource.full_name == "Alice Smith"
assert resource.to_dict() == resource_data
with pytest.raises(AttributeError):
_ = resource.FullName # noqa: WPS122


def test_deep_case_conversion():
resource_data = {"id": "ABC-123", "contact": {"id": "ABC-345", "FullName": "Alice Smith"}}
expected_resource_data = {
"id": "ABC-123",
"contact": {"id": "ABC-345", "FullName": "Alice Smith", "StreetAddress": "123 Main St"},
}

resource = Model(resource_data)
resource.contact.StreetAddress = "123 Main St"

assert resource.contact.full_name == "Alice Smith"
assert resource.contact.street_address == "123 Main St"
assert resource.to_dict() == expected_resource_data

with pytest.raises(AttributeError):
_ = resource.contact.FullName # noqa: WPS122

with pytest.raises(AttributeError):
_ = resource.contact.StreetAddress # noqa: WPS122


def test_repr():
resource_data = {"id": "abc-123", "FullName": "Alice Smith"}

resource = Model(resource_data)

assert repr(resource) == "<Model abc-123>"
assert str(resource) == "<Model abc-123>"


def test_mapping():
class MappingModel(Model): # noqa: WPS431
_attribute_mapping: ClassVar[dict[str, str]] = {
"second_id": "resource_id",
"Full_Name": "name",
}

resource_data = {"id": "abc-123", "second_id": "resource-abc-123", "Full_Name": "Alice Smith"}

resource = MappingModel(resource_data)

assert resource.name == "Alice Smith"
assert resource.resource_id == "resource-abc-123"
assert resource.to_dict() == resource_data