Skip to content

Commit bdb6d5e

Browse files
committed
MPT-18701 Fix incorrect casing
Replaced box for dataclasses
1 parent 66287e3 commit bdb6d5e

File tree

5 files changed

+291
-138
lines changed

5 files changed

+291
-138
lines changed

mpt_api_client/models/model.py

Lines changed: 159 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,186 @@
1-
from typing import Any, ClassVar, Self, override
2-
3-
from box import Box
4-
from box.box import _camel_killer # type: ignore[attr-defined] # noqa: PLC2701
1+
import re
2+
from collections import UserList
3+
from collections.abc import Iterable
4+
from dataclasses import dataclass
5+
from typing import Any, ClassVar, Self, get_args, get_origin, override
56

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

910
ResourceData = dict[str, Any]
1011

11-
_box_safe_attributes: list[str] = ["_box_config", "_attribute_mapping"]
1212

13+
_SNAKE_CASE_BOUNDARY = re.compile(r"([a-z0-9])([A-Z])")
14+
_SNAKE_CASE_ACRONYM = re.compile(r"((?>[A-Z]+))([A-Z][a-z0-9])")
1315

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

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-
)
17+
def to_snake_case(key: str) -> str:
18+
"""Converts a camelCase string to snake_case."""
19+
if "_" in key and key.islower():
20+
return key
21+
# Common pattern for PascalCase/camelCase conversion
22+
snake = _SNAKE_CASE_BOUNDARY.sub(r"\1_\2", key)
23+
snake = _SNAKE_CASE_ACRONYM.sub(r"\1_\2", snake)
24+
return snake.lower().replace("__", "_")
25+
26+
27+
def to_camel_case(key: str) -> str:
28+
"""Converts a snake_case string to camelCase."""
29+
parts = key.split("_")
30+
return parts[0] + "".join(x.title() for x in parts[1:]) # noqa: WPS111 WPS221
31+
32+
33+
class ModelList(UserList[Any]):
34+
"""A list that automatically converts dictionaries to BaseModel objects."""
35+
36+
def __init__(
37+
self,
38+
iterable: Iterable[Any] | None = None,
39+
model_class: type["BaseModel"] | None = None, # noqa: WPS221
40+
) -> None:
41+
self._model_class = model_class or BaseModel
42+
iterable = iterable or []
43+
super().__init__([self._process_item(item) for item in iterable])
2644

2745
@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]
46+
def append(self, item: Any) -> None:
47+
self.data.append(self._process_item(item))
3148

3249
@override
33-
def __setattr__(self, item: str, value: Any) -> None:
34-
if item in _box_safe_attributes:
35-
return object.__setattr__(self, item, value)
50+
def extend(self, iterable: Iterable[Any]) -> None:
51+
self.data.extend(self._process_item(item) for item in iterable)
3652

37-
super().__setattr__(item, value) # type: ignore[no-untyped-call]
38-
return None
53+
@override
54+
def insert(self, index: Any, item: Any) -> None:
55+
self.data.insert(index, self._process_item(item))
3956

4057
@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]
58+
def __setitem__(self, index: Any, item: Any) -> None:
59+
self.data[index] = self._process_item(item)
60+
61+
def _process_item(self, item: Any) -> Any:
62+
if isinstance(item, dict) and not isinstance(item, BaseModel):
63+
return self._model_class(**item)
64+
if isinstance(item, (list, UserList)) and not isinstance(item, ModelList):
65+
return ModelList(item, model_class=self._model_class)
66+
return item
67+
68+
69+
@dataclass
70+
class BaseModel:
71+
"""Base dataclass for models providing object-only access and case conversion."""
72+
73+
def __init__(self, **kwargs: Any) -> None: # noqa: WPS210
74+
"""Processes resource data to convert keys and handle nested structures."""
75+
# Get type hints for field mapping
76+
hints = getattr(self, "__annotations__", {})
77+
78+
for key, value in kwargs.items():
79+
mapped_key = to_snake_case(key)
80+
81+
# Check if there's a type hint for this key
82+
target_class = hints.get(mapped_key)
83+
processed_value = self._process_value(value, target_class=target_class)
84+
object.__setattr__(self, mapped_key, processed_value)
85+
86+
def __getattr__(self, name: str) -> Any:
87+
# 1. Try to find the attribute in __dict__ (includes attributes set in __init__)
88+
if name in self.__dict__:
89+
return self.__dict__[name] # noqa: WPS420 WPS529
90+
91+
# 2. Check for methods or properties
92+
try:
93+
return object.__getattribute__(self, name)
94+
except AttributeError:
95+
pass # noqa: WPS420
96+
97+
raise AttributeError(
98+
f"'{self.__class__.__name__}' object has no attribute '{name}'", # noqa: WPS237
99+
)
45100

46101
@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-
}
102+
def __setattr__(self, name: str, value: Any) -> None:
103+
if name.startswith("_"):
104+
object.__setattr__(self, name, value)
105+
return
106+
107+
snake_name = to_snake_case(name)
108+
109+
# Get target class for value processing if it's a known attribute
110+
hints = getattr(self, "__annotations__", {})
111+
target_class = hints.get(snake_name) or hints.get(name)
112+
113+
processed_value = self._process_value(value, target_class=target_class)
114+
object.__setattr__(self, snake_name, processed_value)
115+
116+
def to_dict(self) -> dict[str, Any]:
117+
"""Returns the resource as a dictionary with original API keys."""
51118
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
56119

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]
120+
# Iterate over all attributes in __dict__ that aren't internal
121+
for key, value in self.__dict__.items():
122+
if key.startswith("_"):
123+
continue
124+
if key == "meta":
125+
continue
63126

127+
original_key = to_camel_case(key)
128+
out_dict[original_key] = self._serialize_value(value)
64129

65-
class Model: # noqa: WPS214
130+
return out_dict
131+
132+
def _serialize_value(self, value: Any) -> Any:
133+
"""Recursively serializes values back to dicts."""
134+
if isinstance(value, BaseModel):
135+
return value.to_dict()
136+
if isinstance(value, (list, UserList)):
137+
return [self._serialize_value(item) for item in value]
138+
return value
139+
140+
def _process_value(self, value: Any, target_class: Any = None) -> Any: # noqa: WPS231 C901
141+
"""Recursively processes values to ensure nested dicts are BaseModels."""
142+
if isinstance(value, dict) and not isinstance(value, BaseModel):
143+
# If a target class is provided and it's a subclass of BaseModel, use it
144+
if (
145+
target_class
146+
and isinstance(target_class, type)
147+
and issubclass(target_class, BaseModel)
148+
):
149+
return target_class(**value)
150+
return BaseModel(**value)
151+
152+
if isinstance(value, (list, UserList)) and not isinstance(value, ModelList):
153+
# Try to determine the model class for the list elements from type hints
154+
model_class = BaseModel
155+
if target_class:
156+
# Handle list[ModelClass]
157+
158+
origin = get_origin(target_class)
159+
if origin is list:
160+
args = get_args(target_class)
161+
if args and isinstance(args[0], type) and issubclass(args[0], BaseModel): # noqa: WPS221
162+
model_class = args[0] # noqa: WPS220
163+
164+
return ModelList(value, model_class=model_class)
165+
# Recursively handle BaseModel if it's already one
166+
if isinstance(value, BaseModel):
167+
return value
168+
return value
169+
170+
171+
class Model(BaseModel):
66172
"""Provides a resource to interact with api data using fluent interfaces."""
67173

68174
_data_key: ClassVar[str | None] = None
69-
_safe_attributes: ClassVar[list[str]] = ["meta", "_box"]
70-
_attribute_mapping: ClassVar[dict[str, str]] = {}
71-
72-
def __init__(self, resource_data: ResourceData | None = None, meta: Meta | None = None) -> None:
73-
self.meta = meta
74-
self._box = MptBox(
75-
resource_data or {},
76-
attribute_mapping=self._attribute_mapping,
77-
)
175+
id: str
176+
177+
def __init__(
178+
self, resource_data: ResourceData | None = None, meta: Meta | None = None, **kwargs: Any
179+
) -> None:
180+
object.__setattr__(self, "meta", meta)
181+
data = dict(resource_data or {})
182+
data.update(kwargs)
183+
super().__init__(**data)
78184

79185
@override
80186
def __repr__(self) -> str:
@@ -84,19 +190,7 @@ def __repr__(self) -> str:
84190
@classmethod
85191
def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None) -> Self:
86192
"""Creates a new resource from ResourceData and Meta."""
87-
return cls(resource_data, meta)
88-
89-
def __getattr__(self, attribute: str) -> Box | Any:
90-
"""Returns the resource data."""
91-
return self._box.__getattr__(attribute)
92-
93-
@override
94-
def __setattr__(self, attribute: str, attribute_value: Any) -> None:
95-
if attribute in self._safe_attributes:
96-
object.__setattr__(self, attribute, attribute_value)
97-
return
98-
99-
self._box.__setattr__(attribute, attribute_value)
193+
return cls(resource_data, meta=meta)
100194

101195
@classmethod
102196
def from_response(cls, response: Response) -> Self:
@@ -114,12 +208,3 @@ def from_response(cls, response: Response) -> Self:
114208
raise TypeError("Response data must be a dict.")
115209
meta = Meta.from_response(response)
116210
return cls.new(response_data, meta)
117-
118-
@property
119-
def id(self) -> str:
120-
"""Returns the resource ID."""
121-
return str(self._box.get("id", "")) # type: ignore[no-untyped-call]
122-
123-
def to_dict(self) -> dict[str, Any]:
124-
"""Returns the resource as a dictionary."""
125-
return self._box.to_dict()

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ classifiers = [
2121
]
2222
dependencies = [
2323
"httpx==0.28.*",
24-
"python-box==7.4.*",
2524
]
2625

2726
[dependency-groups]

0 commit comments

Comments
 (0)