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+ 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 ()
0 commit comments