Skip to content

Commit ac99f10

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

5 files changed

Lines changed: 287 additions & 138 deletions

File tree

mpt_api_client/models/model.py

Lines changed: 155 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,182 @@
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+
def to_snake_case(key: str) -> str:
14+
"""Converts a camelCase string to snake_case."""
15+
if "_" in key and key.islower():
16+
return key
17+
# Common pattern for PascalCase/camelCase conversion
18+
snake = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", key)
19+
snake = re.sub(r"([A-Z]+)([A-Z][a-z0-9])", r"\1_\2", snake)
20+
return snake.lower().replace("__", "_")
1321

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

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-
)
23+
def to_camel_case(key: str) -> str:
24+
"""Converts a snake_case string to camelCase."""
25+
parts = key.split("_")
26+
return parts[0] + "".join(x.title() for x in parts[1:]) # noqa: WPS111 WPS221
27+
28+
29+
class ModelList(UserList[Any]):
30+
"""A list that automatically converts dictionaries to BaseModel objects."""
31+
32+
def __init__(
33+
self,
34+
iterable: Iterable[Any] | None = None,
35+
model_class: type["BaseModel"] | None = None, # noqa: WPS221
36+
) -> None:
37+
self._model_class = model_class or BaseModel
38+
iterable = iterable or []
39+
super().__init__([self._process_item(item) for item in iterable])
2640

2741
@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]
42+
def append(self, item: Any) -> None:
43+
self.data.append(self._process_item(item))
3144

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

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

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

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

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]
116+
# Iterate over all attributes in __dict__ that aren't internal
117+
for key, value in self.__dict__.items():
118+
if key.startswith("_"):
119+
continue
120+
if key == "meta":
121+
continue
122+
123+
original_key = to_camel_case(key)
124+
out_dict[original_key] = self._serialize_value(value)
63125

126+
return out_dict
64127

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

68170
_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-
)
171+
id: str
172+
173+
def __init__(
174+
self, resource_data: ResourceData | None = None, meta: Meta | None = None, **kwargs: Any
175+
) -> None:
176+
object.__setattr__(self, "meta", meta)
177+
data = resource_data or {}
178+
data.update(kwargs)
179+
super().__init__(**data)
78180

79181
@override
80182
def __repr__(self) -> str:
@@ -84,19 +186,7 @@ def __repr__(self) -> str:
84186
@classmethod
85187
def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None) -> Self:
86188
"""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)
189+
return cls(meta=meta, **(resource_data or {}))
100190

101191
@classmethod
102192
def from_response(cls, response: Response) -> Self:
@@ -114,12 +204,3 @@ def from_response(cls, response: Response) -> Self:
114204
raise TypeError("Response data must be a dict.")
115205
meta = Meta.from_response(response)
116206
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)