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
66from mpt_api_client .http .types import Response
77from mpt_api_client .models .meta import Meta
88
99ResourceData = 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 ()
0 commit comments