Skip to content

Commit a7e7c13

Browse files
committed
MPT-18701 Fix incorrect casing
Replaced box for dataclasses
1 parent 8125471 commit a7e7c13

File tree

5 files changed

+269
-138
lines changed

5 files changed

+269
-138
lines changed

mpt_api_client/models/model.py

Lines changed: 157 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,184 @@
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 typing import Any, ClassVar, Self, get_args, get_origin, override
55

66
from mpt_api_client.http.types import Response
77
from mpt_api_client.models.meta import Meta
88

99
ResourceData = dict[str, Any]
1010

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

12+
_SNAKE_CASE_BOUNDARY = re.compile(r"([a-z0-9])([A-Z])")
13+
_SNAKE_CASE_ACRONYM = re.compile(r"(?<=[A-Z])(?=[A-Z][a-z0-9])")
1314

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

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

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

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

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

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

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

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

125+
original_key = to_camel_case(key)
126+
out_dict[original_key] = self._serialize_value(value)
64127

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

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

79183
@override
80184
def __repr__(self) -> str:
@@ -84,19 +188,7 @@ def __repr__(self) -> str:
84188
@classmethod
85189
def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None) -> Self:
86190
"""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)
191+
return cls(resource_data, meta=meta)
100192

101193
@classmethod
102194
def from_response(cls, response: Response) -> Self:
@@ -114,12 +206,3 @@ def from_response(cls, response: Response) -> Self:
114206
raise TypeError("Response data must be a dict.")
115207
meta = Meta.from_response(response)
116208
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)