@@ -54,14 +54,24 @@ def _headers(self) -> Dict[str, str]:
5454 """Build standard OData headers with bearer auth."""
5555 scope = f"{ self .base_url } /.default"
5656 token = self .auth .acquire_token (scope ).access_token
57+ # TODO: add version to User-Agent
5758 return {
5859 "Authorization" : f"Bearer { token } " ,
5960 "Accept" : "application/json" ,
6061 "Content-Type" : "application/json" ,
6162 "OData-MaxVersion" : "4.0" ,
6263 "OData-Version" : "4.0" ,
64+ "User-Agent" : "DataversePythonSDK" ,
6365 }
6466
67+ def _merge_headers (self , headers : Optional [Dict [str , str ]] = None ) -> Dict [str , str ]:
68+ base = self ._headers ()
69+ if not headers :
70+ return base
71+ merged = base .copy ()
72+ merged .update (headers )
73+ return merged
74+
6575 def _raw_request (self , method : str , url : str , ** kwargs ):
6676 return self ._http .request (method , url , ** kwargs )
6777
@@ -71,6 +81,8 @@ def _request(self, method: str, url: str, *, expected: tuple[int, ...] = (200, 2
7181 Returns the raw response for success codes; raises HttpError with extracted
7282 Dataverse error payload fields and correlation identifiers otherwise.
7383 """
84+ headers = kwargs .pop ("headers" , None )
85+ kwargs ["headers" ] = self ._merge_headers (headers )
7486 r = self ._raw_request (method , url , ** kwargs )
7587 if r .status_code in expected :
7688 return r
@@ -149,8 +161,7 @@ def _create_single(self, entity_set: str, logical_name: str, record: Dict[str, A
149161 """
150162 record = self ._convert_labels_to_ints (logical_name , record )
151163 url = f"{ self .api } /{ entity_set } "
152- headers = self ._headers ().copy ()
153- r = self ._request ("post" , url , headers = headers , json = record )
164+ r = self ._request ("post" , url , json = record )
154165
155166 ent_loc = r .headers .get ("OData-EntityId" ) or r .headers .get ("OData-EntityID" )
156167 if ent_loc :
@@ -184,8 +195,7 @@ def _create_multiple(self, entity_set: str, logical_name: str, records: List[Dic
184195 # Bound action form: POST {entity_set}/Microsoft.Dynamics.CRM.CreateMultiple
185196 url = f"{ self .api } /{ entity_set } /Microsoft.Dynamics.CRM.CreateMultiple"
186197 # The action currently returns only Ids; no need to request representation.
187- headers = self ._headers ().copy ()
188- r = self ._request ("post" , url , headers = headers , json = payload )
198+ r = self ._request ("post" , url , json = payload )
189199 try :
190200 body = r .json () if r .text else {}
191201 except ValueError :
@@ -302,9 +312,7 @@ def _update(self, logical_name: str, key: str, data: Dict[str, Any]) -> None:
302312 data = self ._convert_labels_to_ints (logical_name , data )
303313 entity_set = self ._entity_set_from_logical (logical_name )
304314 url = f"{ self .api } /{ entity_set } { self ._format_key (key )} "
305- headers = self ._headers ().copy ()
306- headers ["If-Match" ] = "*"
307- r = self ._request ("patch" , url , headers = headers , json = data )
315+ r = self ._request ("patch" , url , headers = {"If-Match" : "*" }, json = data )
308316
309317 def _update_multiple (self , entity_set : str , logical_name : str , records : List [Dict [str , Any ]]) -> None :
310318 """Bulk update existing records via the collection-bound UpdateMultiple action.
@@ -353,18 +361,15 @@ def _update_multiple(self, entity_set: str, logical_name: str, records: List[Dic
353361
354362 payload = {"Targets" : enriched }
355363 url = f"{ self .api } /{ entity_set } /Microsoft.Dynamics.CRM.UpdateMultiple"
356- headers = self ._headers ().copy ()
357- r = self ._request ("post" , url , headers = headers , json = payload )
364+ r = self ._request ("post" , url , json = payload )
358365 # Intentionally ignore response content: no stable contract for IDs across environments.
359366 return None
360367
361368 def _delete (self , logical_name : str , key : str ) -> None :
362369 """Delete a record by GUID or alternate key."""
363370 entity_set = self ._entity_set_from_logical (logical_name )
364371 url = f"{ self .api } /{ entity_set } { self ._format_key (key )} "
365- headers = self ._headers ().copy ()
366- headers ["If-Match" ] = "*"
367- self ._request ("delete" , url , headers = headers )
372+ self ._request ("delete" , url , headers = {"If-Match" : "*" })
368373
369374 def _get (self , logical_name : str , key : str , select : Optional [str ] = None ) -> Dict [str , Any ]:
370375 """Retrieve a single record.
@@ -383,7 +388,7 @@ def _get(self, logical_name: str, key: str, select: Optional[str] = None) -> Dic
383388 params ["$select" ] = select
384389 entity_set = self ._entity_set_from_logical (logical_name )
385390 url = f"{ self .api } /{ entity_set } { self ._format_key (key )} "
386- r = self ._request ("get" , url , headers = self . _headers (), params = params )
391+ r = self ._request ("get" , url , params = params )
387392 return r .json ()
388393
389394 def _get_multiple (
@@ -421,13 +426,14 @@ def _get_multiple(
421426 A page of records from the Web API (the "value" array for each page).
422427 """
423428
424- headers = self . _headers (). copy ()
429+ extra_headers : Dict [ str , str ] = {}
425430 if page_size is not None :
426431 ps = int (page_size )
427432 if ps > 0 :
428- headers ["Prefer" ] = f"odata.maxpagesize={ ps } "
433+ extra_headers ["Prefer" ] = f"odata.maxpagesize={ ps } "
429434
430435 def _do_request (url : str , * , params : Optional [Dict [str , Any ]] = None ) -> Dict [str , Any ]:
436+ headers = extra_headers if extra_headers else None
431437 r = self ._request ("get" , url , headers = headers , params = params )
432438 try :
433439 return r .json ()
@@ -500,10 +506,9 @@ def _query_sql(self, sql: str) -> list[dict[str, Any]]:
500506
501507 entity_set = self ._entity_set_from_logical (logical )
502508 # Issue GET /{entity_set}?sql=<query>
503- headers = self ._headers ().copy ()
504509 url = f"{ self .api } /{ entity_set } "
505510 params = {"sql" : sql }
506- r = self ._request ("get" , url , headers = headers , params = params )
511+ r = self ._request ("get" , url , params = params )
507512 try :
508513 body = r .json ()
509514 except ValueError :
@@ -554,7 +559,7 @@ def _entity_set_from_logical(self, logical: str) -> str:
554559 "$select" : "LogicalName,EntitySetName,PrimaryIdAttribute" ,
555560 "$filter" : f"LogicalName eq '{ logical_escaped } '" ,
556561 }
557- r = self ._request ("get" , url , headers = self . _headers (), params = params )
562+ r = self ._request ("get" , url , params = params )
558563 try :
559564 body = r .json ()
560565 items = body .get ("value" , []) if isinstance (body , dict ) else []
@@ -599,7 +604,7 @@ def _get_entity_by_schema(self, schema_name: str) -> Optional[Dict[str, Any]]:
599604 "$select" : "MetadataId,LogicalName,SchemaName,EntitySetName" ,
600605 "$filter" : f"SchemaName eq '{ schema_escaped } '" ,
601606 }
602- r = self ._request ("get" , url , headers = self . _headers (), params = params )
607+ r = self ._request ("get" , url , params = params )
603608 items = r .json ().get ("value" , [])
604609 return items [0 ] if items else None
605610
@@ -617,8 +622,7 @@ def _create_entity(self, schema_name: str, display_name: str, attributes: List[D
617622 "IsActivity" : False ,
618623 "Attributes" : attributes ,
619624 }
620- headers = self ._headers ()
621- r = self ._request ("post" , url , headers = headers , json = payload )
625+ r = self ._request ("post" , url , json = payload )
622626 ent = self ._wait_for_entity_ready (schema_name )
623627 if not ent or not ent .get ("EntitySetName" ):
624628 raise RuntimeError (
@@ -791,20 +795,21 @@ def _optionset_map(self, logical_name: str, attr_logical: str) -> Optional[Dict[
791795 # Retry up to 3 times on 404 (new or not-yet-published attribute metadata). If still 404, raise.
792796 r_type = None
793797 for attempt in range (3 ):
794- r_type = self . _raw_request ( "get" , url_type , headers = self . _headers ())
795- if r_type . status_code != 404 :
798+ try :
799+ r_type = self . _request ( "get" , url_type )
796800 break
797- if attempt < 2 :
798- # Exponential-ish backoff: 0.4s, 0.8s
799- time .sleep (0.4 * (2 ** attempt ))
800- if r_type .status_code == 404 :
801- # After retries we still cannot find the attribute definition – treat as fatal so caller sees a clear error.
802- raise RuntimeError (
803- f"Picklist attribute metadata not found after retries: entity='{ logical_name } ' attribute='{ attr_logical } ' (404)"
804- )
805- if not (200 <= r_type .status_code < 300 ):
806- # Re-issue via _send to raise structured HttpError (rare path)
807- self ._request ("get" , url_type , headers = self ._headers ())
801+ except HttpError as err :
802+ if getattr (err , "status_code" , None ) == 404 :
803+ if attempt < 2 :
804+ # Exponential-ish backoff: 0.4s, 0.8s
805+ time .sleep (0.4 * (2 ** attempt ))
806+ continue
807+ raise RuntimeError (
808+ f"Picklist attribute metadata not found after retries: entity='{ logical_name } ' attribute='{ attr_logical } ' (404)"
809+ ) from err
810+ raise
811+ if r_type is None :
812+ raise RuntimeError ("Failed to retrieve attribute metadata due to repeated request failures." )
808813
809814 body_type = r_type .json ()
810815 items = body_type .get ("value" , []) if isinstance (body_type , dict ) else []
@@ -824,15 +829,20 @@ def _optionset_map(self, logical_name: str, attr_logical: str) -> Optional[Dict[
824829 # Step 2 fetch with retries: expanded OptionSet (cast form first)
825830 r_opts = None
826831 for attempt in range (3 ):
827- r_opts = self . _raw_request ( "get" , cast_url , headers = self . _headers ())
828- if r_opts . status_code != 404 :
832+ try :
833+ r_opts = self . _request ( "get" , cast_url )
829834 break
830- if attempt < 2 :
831- time .sleep (0.4 * (2 ** attempt )) # 0.4s, 0.8s
832- if r_opts .status_code == 404 :
833- raise RuntimeError (f"Picklist OptionSet metadata not found after retries: entity='{ logical_name } ' attribute='{ attr_logical } ' (404)" )
834- if not (200 <= r_opts .status_code < 300 ):
835- self ._request ("get" , cast_url , headers = self ._headers ())
835+ except HttpError as err :
836+ if getattr (err , "status_code" , None ) == 404 :
837+ if attempt < 2 :
838+ time .sleep (0.4 * (2 ** attempt )) # 0.4s, 0.8s
839+ continue
840+ raise RuntimeError (
841+ f"Picklist OptionSet metadata not found after retries: entity='{ logical_name } ' attribute='{ attr_logical } ' (404)"
842+ ) from err
843+ raise
844+ if r_opts is None :
845+ raise RuntimeError ("Failed to retrieve picklist OptionSet metadata due to repeated request failures." )
836846
837847 attr_full = {}
838848 try :
@@ -993,7 +1003,7 @@ def _list_tables(self) -> List[Dict[str, Any]]:
9931003 params = {
9941004 "$filter" : "IsPrivate eq false"
9951005 }
996- r = self ._request ("get" , url , headers = self . _headers (), params = params )
1006+ r = self ._request ("get" , url , params = params )
9971007 return r .json ().get ("value" , [])
9981008
9991009 def _delete_table (self , tablename : str ) -> None :
@@ -1004,8 +1014,7 @@ def _delete_table(self, tablename: str) -> None:
10041014 raise RuntimeError (f"Table '{ entity_schema } ' not found." )
10051015 metadata_id = ent ["MetadataId" ]
10061016 url = f"{ self .api } /EntityDefinitions({ metadata_id } )"
1007- headers = self ._headers ()
1008- r = self ._request ("delete" , url , headers = headers )
1017+ r = self ._request ("delete" , url )
10091018
10101019 def _create_table (self , tablename : str , schema : Dict [str , Any ]) -> Dict [str , Any ]:
10111020 # Accept a friendly name and construct a default schema under 'new_'.
0 commit comments