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
67from mpt_api_client .http .types import Response
78from mpt_api_client .models .meta import Meta
89
910ResourceData = 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 ()
0 commit comments