Skip to content

Commit fa6ce60

Browse files
Merge pull request #23 from microsoft/users/zhaodongwang/UserAgentHeaderPlusHeadersRefactor
add user agent to calls and consolidate headers calls into _request
2 parents a1d010b + 0740ea8 commit fa6ce60

3 files changed

Lines changed: 76 additions & 67 deletions

File tree

examples/quickstart_file_upload.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ def ensure_file_attribute_generic(schema_name: str, label: str, key_prefix: str)
191191
f"{odata.api}/EntityDefinitions({meta_id})/Attributes?$select=SchemaName&$filter="
192192
f"SchemaName eq '{schema_name}'"
193193
)
194-
r = odata._request("get", url, headers=odata._headers())
194+
r = odata._request("get", url)
195195
val = []
196196
try:
197197
val = r.json().get("value", [])
@@ -215,7 +215,7 @@ def ensure_file_attribute_generic(schema_name: str, label: str, key_prefix: str)
215215
}
216216
try:
217217
url = f"{odata.api}/EntityDefinitions({meta_id})/Attributes"
218-
r = odata._request("post", url, headers=odata._headers(), json=payload)
218+
r = odata._request("post", url, json=payload)
219219
print({f"{key_prefix}_file_attribute_created": True})
220220
time.sleep(2)
221221
return True
@@ -288,7 +288,7 @@ def get_dataset_info(file_path: Path):
288288
print({"small_upload_completed": True, "small_source_size": small_file_size})
289289
odata = client._get_odata()
290290
dl_url_single = f"{odata.api}/{entity_set}({record_id})/{small_file_attr_logical}/$value" # raw entity_set URL OK
291-
resp_single = odata._request("get", dl_url_single, headers=odata._headers())
291+
resp_single = odata._request("get", dl_url_single)
292292
content_single = resp_single.content or b""
293293
import hashlib # noqa: WPS433
294294
downloaded_hash = hashlib.sha256(content_single).hexdigest() if content_single else None
@@ -313,7 +313,7 @@ def get_dataset_info(file_path: Path):
313313
mode="small",
314314
))
315315
print({"small_replace_upload_completed": True, "small_replace_source_size": replace_size_small})
316-
resp_single_replace = odata._request("get", dl_url_single, headers=odata._headers())
316+
resp_single_replace = odata._request("get", dl_url_single)
317317
content_single_replace = resp_single_replace.content or b""
318318
downloaded_hash_replace = hashlib.sha256(content_single_replace).hexdigest() if content_single_replace else None
319319
hash_match_replace = (downloaded_hash_replace == replace_hash_small) if (downloaded_hash_replace and replace_hash_small) else None
@@ -343,7 +343,7 @@ def get_dataset_info(file_path: Path):
343343
print({"chunk_upload_completed": True})
344344
odata = client._get_odata()
345345
dl_url_chunk = f"{odata.api}/{entity_set}({record_id})/{chunk_file_attr_logical}/$value" # raw entity_set for download
346-
resp_chunk = odata._request("get", dl_url_chunk, headers=odata._headers())
346+
resp_chunk = odata._request("get", dl_url_chunk)
347347
content_chunk = resp_chunk.content or b""
348348
import hashlib # noqa: WPS433
349349
dst_hash_chunk = hashlib.sha256(content_chunk).hexdigest() if content_chunk else None
@@ -367,7 +367,7 @@ def get_dataset_info(file_path: Path):
367367
mode="chunk",
368368
))
369369
print({"chunk_replace_upload_completed": True})
370-
resp_chunk_replace = odata._request("get", dl_url_chunk, headers=odata._headers())
370+
resp_chunk_replace = odata._request("get", dl_url_chunk)
371371
content_chunk_replace = resp_chunk_replace.content or b""
372372
dst_hash_chunk_replace = hashlib.sha256(content_chunk_replace).hexdigest() if content_chunk_replace else None
373373
hash_match_chunk_replace = (dst_hash_chunk_replace == replace_hash_chunk) if (dst_hash_chunk_replace and replace_hash_chunk) else None

src/dataverse_sdk/odata.py

Lines changed: 54 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -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_'.

src/dataverse_sdk/odata_upload_files.py

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -94,16 +94,16 @@ def _upload_file_small(
9494
fname = os.path.basename(path)
9595
key = self._format_key(record_id)
9696
url = f"{self.api}/{entity_set}{key}/{file_name_attribute}"
97-
headers = self._headers().copy()
98-
headers["Content-Type"] = content_type or "application/octet-stream"
99-
headers["Accept"] = "application/json"
100-
headers["x-ms-file-name"] = fname
97+
headers = {
98+
"Content-Type": content_type or "application/octet-stream",
99+
"x-ms-file-name": fname,
100+
}
101101
if if_none_match:
102102
headers["If-None-Match"] = "null"
103103
else:
104104
headers["If-Match"] = "*"
105105
# Single PATCH upload; allow default success codes (includes 204)
106-
self._send("patch", url, headers=headers, data=data)
106+
self._request("patch", url, headers=headers, data=data)
107107
return None
108108

109109
def _upload_file_chunk(
@@ -146,14 +146,14 @@ def _upload_file_chunk(
146146
fname = os.path.basename(path)
147147
key = self._format_key(record_id)
148148
init_url = f"{self.api}/{entity_set}{key}/{file_name_attribute}?x-ms-file-name={quote(fname)}"
149-
headers = self._headers().copy()
150-
headers["x-ms-transfer-mode"] = "chunked"
151-
headers["Accept"] = "application/json"
149+
headers = {
150+
"x-ms-transfer-mode": "chunked",
151+
}
152152
if if_none_match:
153153
headers["If-None-Match"] = "null"
154154
else:
155155
headers["If-Match"] = "*"
156-
r_init = self._send("patch", init_url, headers=headers, data=b"")
156+
r_init = self._request("patch", init_url, headers=headers, data=b"")
157157
location = r_init.headers.get("Location") or r_init.headers.get("location")
158158
if not location:
159159
raise RuntimeError("Missing Location header with sessiontoken for chunked upload")
@@ -174,13 +174,13 @@ def _upload_file_chunk(
174174
break
175175
start = uploaded_bytes
176176
end = start + len(chunk) - 1
177-
c_headers = self._headers().copy()
178-
c_headers["x-ms-file-name"] = fname
179-
c_headers["Content-Type"] = "application/octet-stream"
180-
c_headers["Accept"] = "application/json"
181-
c_headers["Content-Range"] = f"bytes {start}-{end}/{total_size}"
182-
c_headers["Content-Length"] = str(len(chunk))
177+
c_headers = {
178+
"x-ms-file-name": fname,
179+
"Content-Type": "application/octet-stream",
180+
"Content-Range": f"bytes {start}-{end}/{total_size}",
181+
"Content-Length": str(len(chunk)),
182+
}
183183
# Each chunk returns 206 (partial) or 204 (final). Accept both.
184-
self._send("patch", location, headers=c_headers, data=chunk, expected=(206, 204))
184+
self._request("patch", location, headers=c_headers, data=chunk, expected=(206, 204))
185185
uploaded_bytes += len(chunk)
186186
return None

0 commit comments

Comments
 (0)