Skip to content

Commit 8e04835

Browse files
authored
MPT-15233 Fix for snake-case to camel-case conversion (#111)
2 parents 5a75c11 + 2be52fd commit 8e04835

File tree

3 files changed

+130
-7
lines changed

3 files changed

+130
-7
lines changed

mpt_api_client/models/model.py

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,85 @@
11
from typing import Any, ClassVar, Self, override
22

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

56
from mpt_api_client.http.types import Response
67
from mpt_api_client.models.meta import Meta
78

89
ResourceData = dict[str, Any]
910

11+
_box_safe_attributes: list[str] = ["_box_config", "_attribute_mapping"]
1012

11-
class Model:
13+
14+
class MptBox(Box):
15+
"""python-box that preserves camelCase keys when converted to json."""
16+
17+
def __init__(self, *args, attribute_mapping: dict[str, str] | None = None, **_): # type: ignore[no-untyped-def]
18+
attribute_mapping = attribute_mapping or {}
19+
self._attribute_mapping = attribute_mapping
20+
super().__init__(
21+
*args,
22+
camel_killer_box=False,
23+
default_box=False,
24+
default_box_create_on_get=False,
25+
)
26+
27+
@override
28+
def __setitem__(self, key, value): # type: ignore[no-untyped-def]
29+
mapped_key = self._prep_key(key)
30+
super().__setitem__(mapped_key, value) # type: ignore[no-untyped-call]
31+
32+
@override
33+
def __setattr__(self, item: str, value: Any) -> None:
34+
if item in _box_safe_attributes:
35+
return object.__setattr__(self, item, value)
36+
37+
super().__setattr__(item, value) # type: ignore[no-untyped-call]
38+
return None
39+
40+
@override
41+
def __getattr__(self, item: str) -> Any:
42+
if item in _box_safe_attributes:
43+
return object.__getattribute__(self, item)
44+
return super().__getattr__(item) # type: ignore[no-untyped-call]
45+
46+
@override
47+
def to_dict(self) -> dict[str, Any]: # noqa: WPS210
48+
reverse_mapping = {
49+
mapped_key: original_key for original_key, mapped_key in self._attribute_mapping.items()
50+
}
51+
out_dict = {}
52+
for parsed_key, item_value in super().to_dict().items():
53+
original_key = reverse_mapping[parsed_key]
54+
out_dict[original_key] = item_value
55+
return out_dict
56+
57+
def _prep_key(self, key: str) -> str:
58+
try:
59+
return self._attribute_mapping[key]
60+
except KeyError:
61+
self._attribute_mapping[key] = _camel_killer(key)
62+
return self._attribute_mapping[key]
63+
64+
65+
class Model: # noqa: WPS214
1266
"""Provides a resource to interact with api data using fluent interfaces."""
1367

1468
_data_key: ClassVar[str | None] = None
15-
_safe_attributes: ClassVar[list[str]] = ["meta", "_resource_data"]
69+
_safe_attributes: ClassVar[list[str]] = ["meta", "_box"]
70+
_attribute_mapping: ClassVar[dict[str, str]] = {}
1671

1772
def __init__(self, resource_data: ResourceData | None = None, meta: Meta | None = None) -> None:
1873
self.meta = meta
19-
self._resource_data = Box(resource_data or {}, camel_killer_box=True, default_box=False)
74+
self._box = MptBox(
75+
resource_data or {},
76+
attribute_mapping=self._attribute_mapping,
77+
)
78+
79+
@override
80+
def __repr__(self) -> str:
81+
class_name = self.__class__.__name__
82+
return f"<{class_name} {self.id}>"
2083

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

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

3093
@override
3194
def __setattr__(self, attribute: str, attribute_value: Any) -> None:
3295
if attribute in self._safe_attributes:
3396
object.__setattr__(self, attribute, attribute_value)
3497
return
3598

36-
self._resource_data.__setattr__(attribute, attribute_value) # type: ignore[no-untyped-call]
99+
self._box.__setattr__(attribute, attribute_value)
37100

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

60123
def to_dict(self) -> dict[str, Any]:
61124
"""Returns the resource as a dictionary."""
62-
return self._resource_data.to_dict()
125+
return self._box.to_dict()

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ per-file-ignores =
3535
mpt_api_client/mpt_client.py: WPS214 WPS235
3636
mpt_api_client/http/mixins.py: WPS202
3737
mpt_api_client/resources/*: WPS215
38+
mpt_api_client/models/model.py: WPS215 WPS110
3839
mpt_api_client/resources/accounts/*.py: WPS202 WPS215 WPS214
3940
mpt_api_client/resources/billing/*.py: WPS202 WPS204 WPS214 WPS215
4041
mpt_api_client/resources/catalog/*.py: WPS110 WPS214 WPS215

tests/unit/models/resource/test_resource.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import ClassVar
2+
13
import pytest
24
from httpx import Response
35

@@ -70,3 +72,60 @@ def test_id_property_with_numeric_id():
7072

7173
assert resource.id == "1024"
7274
assert isinstance(resource.id, str)
75+
76+
77+
def test_case_conversion():
78+
resource_data = {"id": "abc-123", "FullName": "Alice Smith"}
79+
80+
resource = Model(resource_data)
81+
82+
assert resource.full_name == "Alice Smith"
83+
assert resource.to_dict() == resource_data
84+
with pytest.raises(AttributeError):
85+
_ = resource.FullName # noqa: WPS122
86+
87+
88+
def test_deep_case_conversion():
89+
resource_data = {"id": "ABC-123", "contact": {"id": "ABC-345", "FullName": "Alice Smith"}}
90+
expected_resource_data = {
91+
"id": "ABC-123",
92+
"contact": {"id": "ABC-345", "FullName": "Alice Smith", "StreetAddress": "123 Main St"},
93+
}
94+
95+
resource = Model(resource_data)
96+
resource.contact.StreetAddress = "123 Main St"
97+
98+
assert resource.contact.full_name == "Alice Smith"
99+
assert resource.contact.street_address == "123 Main St"
100+
assert resource.to_dict() == expected_resource_data
101+
102+
with pytest.raises(AttributeError):
103+
_ = resource.contact.FullName # noqa: WPS122
104+
105+
with pytest.raises(AttributeError):
106+
_ = resource.contact.StreetAddress # noqa: WPS122
107+
108+
109+
def test_repr():
110+
resource_data = {"id": "abc-123", "FullName": "Alice Smith"}
111+
112+
resource = Model(resource_data)
113+
114+
assert repr(resource) == "<Model abc-123>"
115+
assert str(resource) == "<Model abc-123>"
116+
117+
118+
def test_mapping():
119+
class MappingModel(Model): # noqa: WPS431
120+
_attribute_mapping: ClassVar[dict[str, str]] = {
121+
"second_id": "resource_id",
122+
"Full_Name": "name",
123+
}
124+
125+
resource_data = {"id": "abc-123", "second_id": "resource-abc-123", "Full_Name": "Alice Smith"}
126+
127+
resource = MappingModel(resource_data)
128+
129+
assert resource.name == "Alice Smith"
130+
assert resource.resource_id == "resource-abc-123"
131+
assert resource.to_dict() == resource_data

0 commit comments

Comments
 (0)