Skip to content

Commit 6e80756

Browse files
Abel Milashclaude
andcommitted
Move multipart helpers to after _BatchBase class in _batch_base.py
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3b05fc8 commit 6e80756

1 file changed

Lines changed: 134 additions & 134 deletions

File tree

src/PowerPlatform/Dataverse/data/_batch_base.py

Lines changed: 134 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -252,140 +252,6 @@ class _BatchContext(Protocol):
252252
records: Any # used by BatchDataFrameOperations to delegate create/update/delete
253253

254254

255-
# ---------------------------------------------------------------------------
256-
# Multipart parsing helpers
257-
# ---------------------------------------------------------------------------
258-
259-
260-
def _raise_top_level_batch_error(response: Any) -> None:
261-
"""Parse a non-multipart batch response and raise HttpError with the service message.
262-
263-
Dataverse returns ``application/json`` with an ``{"error": {...}}`` payload when
264-
it rejects the batch request at the HTTP level (e.g. malformed multipart body,
265-
missing OData headers). This helper surfaces that detail instead of silently
266-
returning an empty ``BatchResult``.
267-
"""
268-
status_code: int = getattr(response, "status_code", 0)
269-
service_error_code: Optional[str] = None
270-
try:
271-
payload = response.json()
272-
error = payload.get("error", {})
273-
service_error_code = error.get("code") or None
274-
message: str = error.get("message") or response.text or "Unexpected non-multipart response from $batch"
275-
except Exception:
276-
message = (getattr(response, "text", None) or "") or "Unexpected non-multipart response from $batch"
277-
raise HttpError(
278-
message=f"Batch request rejected by Dataverse: {message}",
279-
status_code=status_code,
280-
subcode=_http_subcode(status_code) if status_code else None,
281-
service_error_code=service_error_code,
282-
)
283-
284-
285-
_BOUNDARY_RE = re.compile(r'boundary="?([^";,\s]+)"?', re.IGNORECASE)
286-
287-
288-
def _extract_boundary(content_type: str) -> Optional[str]:
289-
m = _BOUNDARY_RE.search(content_type)
290-
return m.group(1) if m else None
291-
292-
293-
def _split_multipart(body: str, boundary: str) -> List[Tuple[Dict[str, str], str]]:
294-
delimiter = f"--{boundary}"
295-
parts: List[Tuple[Dict[str, str], str]] = []
296-
lines = body.replace("\r\n", "\n").split("\n")
297-
current: List[str] = []
298-
in_part = False
299-
for line in lines:
300-
stripped = line.rstrip("\r")
301-
if stripped == delimiter:
302-
if in_part and current:
303-
parts.append(_parse_mime_part("\n".join(current)))
304-
current = []
305-
in_part = True
306-
elif stripped == f"{delimiter}--":
307-
if in_part and current:
308-
parts.append(_parse_mime_part("\n".join(current)))
309-
break
310-
elif in_part:
311-
current.append(line)
312-
return parts
313-
314-
315-
def _parse_mime_part(raw: str) -> Tuple[Dict[str, str], str]:
316-
if "\n\n" in raw:
317-
header_block, body = raw.split("\n\n", 1)
318-
else:
319-
header_block, body = raw, ""
320-
headers: Dict[str, str] = {}
321-
for line in header_block.splitlines():
322-
if ":" in line:
323-
name, _, value = line.partition(":")
324-
headers[name.strip().lower()] = value.strip()
325-
return headers, body.strip()
326-
327-
328-
def _parse_http_response_part(text: str, content_id: Optional[str]) -> Optional[BatchItemResponse]:
329-
lines = text.replace("\r\n", "\n").splitlines()
330-
if not lines:
331-
return None
332-
status_line = ""
333-
idx = 0
334-
for i, line in enumerate(lines):
335-
if line.startswith("HTTP/"):
336-
status_line = line
337-
idx = i + 1
338-
break
339-
if not status_line:
340-
return None
341-
parts = status_line.split(" ", 2)
342-
if len(parts) < 2:
343-
return None
344-
try:
345-
status_code = int(parts[1])
346-
except ValueError:
347-
return None
348-
resp_headers: Dict[str, str] = {}
349-
body_start = idx
350-
for i in range(idx, len(lines)):
351-
if lines[i] == "":
352-
body_start = i + 1
353-
break
354-
if ":" in lines[i]:
355-
name, _, value = lines[i].partition(":")
356-
resp_headers[name.strip().lower()] = value.strip()
357-
entity_id: Optional[str] = None
358-
odata_id = resp_headers.get("odata-entityid", "")
359-
if odata_id:
360-
m = _GUID_RE.search(odata_id)
361-
if m:
362-
entity_id = m.group(0)
363-
body_text = "\n".join(lines[body_start:]).strip()
364-
data: Optional[Dict[str, Any]] = None
365-
error_message: Optional[str] = None
366-
error_code: Optional[str] = None
367-
if body_text:
368-
try:
369-
parsed = json.loads(body_text)
370-
if isinstance(parsed, dict):
371-
err = parsed.get("error")
372-
if isinstance(err, dict):
373-
error_message = err.get("message")
374-
error_code = err.get("code")
375-
else:
376-
data = parsed
377-
except (json.JSONDecodeError, ValueError):
378-
pass
379-
return BatchItemResponse(
380-
status_code=status_code,
381-
content_id=content_id,
382-
entity_id=entity_id,
383-
data=data,
384-
error_message=error_message,
385-
error_code=error_code,
386-
)
387-
388-
389255
# ---------------------------------------------------------------------------
390256
# Batch base: pure serialisation and pure table resolvers
391257
# ---------------------------------------------------------------------------
@@ -528,3 +394,137 @@ def _parse_batch_response(self, response: Any) -> BatchResult:
528394
if item is not None:
529395
responses.append(item)
530396
return BatchResult(responses=responses)
397+
398+
399+
# ---------------------------------------------------------------------------
400+
# Multipart parsing helpers
401+
# ---------------------------------------------------------------------------
402+
403+
404+
def _raise_top_level_batch_error(response: Any) -> None:
405+
"""Parse a non-multipart batch response and raise HttpError with the service message.
406+
407+
Dataverse returns ``application/json`` with an ``{"error": {...}}`` payload when
408+
it rejects the batch request at the HTTP level (e.g. malformed multipart body,
409+
missing OData headers). This helper surfaces that detail instead of silently
410+
returning an empty ``BatchResult``.
411+
"""
412+
status_code: int = getattr(response, "status_code", 0)
413+
service_error_code: Optional[str] = None
414+
try:
415+
payload = response.json()
416+
error = payload.get("error", {})
417+
service_error_code = error.get("code") or None
418+
message: str = error.get("message") or response.text or "Unexpected non-multipart response from $batch"
419+
except Exception:
420+
message = (getattr(response, "text", None) or "") or "Unexpected non-multipart response from $batch"
421+
raise HttpError(
422+
message=f"Batch request rejected by Dataverse: {message}",
423+
status_code=status_code,
424+
subcode=_http_subcode(status_code) if status_code else None,
425+
service_error_code=service_error_code,
426+
)
427+
428+
429+
_BOUNDARY_RE = re.compile(r'boundary="?([^";,\s]+)"?', re.IGNORECASE)
430+
431+
432+
def _extract_boundary(content_type: str) -> Optional[str]:
433+
m = _BOUNDARY_RE.search(content_type)
434+
return m.group(1) if m else None
435+
436+
437+
def _split_multipart(body: str, boundary: str) -> List[Tuple[Dict[str, str], str]]:
438+
delimiter = f"--{boundary}"
439+
parts: List[Tuple[Dict[str, str], str]] = []
440+
lines = body.replace("\r\n", "\n").split("\n")
441+
current: List[str] = []
442+
in_part = False
443+
for line in lines:
444+
stripped = line.rstrip("\r")
445+
if stripped == delimiter:
446+
if in_part and current:
447+
parts.append(_parse_mime_part("\n".join(current)))
448+
current = []
449+
in_part = True
450+
elif stripped == f"{delimiter}--":
451+
if in_part and current:
452+
parts.append(_parse_mime_part("\n".join(current)))
453+
break
454+
elif in_part:
455+
current.append(line)
456+
return parts
457+
458+
459+
def _parse_mime_part(raw: str) -> Tuple[Dict[str, str], str]:
460+
if "\n\n" in raw:
461+
header_block, body = raw.split("\n\n", 1)
462+
else:
463+
header_block, body = raw, ""
464+
headers: Dict[str, str] = {}
465+
for line in header_block.splitlines():
466+
if ":" in line:
467+
name, _, value = line.partition(":")
468+
headers[name.strip().lower()] = value.strip()
469+
return headers, body.strip()
470+
471+
472+
def _parse_http_response_part(text: str, content_id: Optional[str]) -> Optional[BatchItemResponse]:
473+
lines = text.replace("\r\n", "\n").splitlines()
474+
if not lines:
475+
return None
476+
status_line = ""
477+
idx = 0
478+
for i, line in enumerate(lines):
479+
if line.startswith("HTTP/"):
480+
status_line = line
481+
idx = i + 1
482+
break
483+
if not status_line:
484+
return None
485+
parts = status_line.split(" ", 2)
486+
if len(parts) < 2:
487+
return None
488+
try:
489+
status_code = int(parts[1])
490+
except ValueError:
491+
return None
492+
resp_headers: Dict[str, str] = {}
493+
body_start = idx
494+
for i in range(idx, len(lines)):
495+
if lines[i] == "":
496+
body_start = i + 1
497+
break
498+
if ":" in lines[i]:
499+
name, _, value = lines[i].partition(":")
500+
resp_headers[name.strip().lower()] = value.strip()
501+
entity_id: Optional[str] = None
502+
odata_id = resp_headers.get("odata-entityid", "")
503+
if odata_id:
504+
m = _GUID_RE.search(odata_id)
505+
if m:
506+
entity_id = m.group(0)
507+
body_text = "\n".join(lines[body_start:]).strip()
508+
data: Optional[Dict[str, Any]] = None
509+
error_message: Optional[str] = None
510+
error_code: Optional[str] = None
511+
if body_text:
512+
try:
513+
parsed = json.loads(body_text)
514+
if isinstance(parsed, dict):
515+
err = parsed.get("error")
516+
if isinstance(err, dict):
517+
error_message = err.get("message")
518+
error_code = err.get("code")
519+
else:
520+
data = parsed
521+
except (json.JSONDecodeError, ValueError):
522+
pass
523+
return BatchItemResponse(
524+
status_code=status_code,
525+
content_id=content_id,
526+
entity_id=entity_id,
527+
data=data,
528+
error_message=error_message,
529+
error_code=error_code,
530+
)

0 commit comments

Comments
 (0)