@@ -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