diff --git a/mpt_api_client/models/model.py b/mpt_api_client/models/model.py index 60b82e14..321a63c0 100644 --- a/mpt_api_client/models/model.py +++ b/mpt_api_client/models/model.py @@ -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: @@ -25,7 +88,7 @@ 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: @@ -33,7 +96,7 @@ def __setattr__(self, attribute: str, attribute_value: Any) -> None: 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: @@ -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() diff --git a/setup.cfg b/setup.cfg index 25dcf8b0..766345c6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 diff --git a/tests/unit/models/resource/test_resource.py b/tests/unit/models/resource/test_resource.py index 314999bf..52ace063 100644 --- a/tests/unit/models/resource/test_resource.py +++ b/tests/unit/models/resource/test_resource.py @@ -1,3 +1,5 @@ +from typing import ClassVar + import pytest from httpx import Response @@ -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) == "" + assert str(resource) == "" + + +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