2323 "OneDriveProvider" ,
2424 "encode_identifier" ,
2525 "decode_identifier" ,
26+ "split_drive_path" ,
2627]
2728
2829
30+ def split_drive_path (path : str ) -> list [str ]:
31+ """Split a OneDrive path into safe folder/file segments."""
32+ out : list [str ] = []
33+ for raw in str (path or "" ).replace ("\\ " , "/" ).split ("/" ):
34+ name = raw .strip ()
35+ if not name or name in {"." , ".." }:
36+ continue
37+ if "/" in name or "\\ " in name or "\x00 " in name :
38+ continue
39+ out .append (name [:255 ])
40+ if len (out ) >= 32 :
41+ break
42+ return out
43+
44+
2945class CloudProviderError (Exception ):
3046 """Raised when a cloud provider encounters an operational error."""
3147
@@ -119,6 +135,7 @@ def upload_file(
119135 parent_id : Optional [str ] = None ,
120136 size : Optional [int ] = None ,
121137 content_type : Optional [str ] = None ,
138+ conflict : str = "rename" ,
122139 ) -> CloudFile : # pragma: no cover - interface
123140 raise NotImplementedError
124141
@@ -402,6 +419,7 @@ def upload_file(
402419 parent_id : Optional [str ] = None ,
403420 size : Optional [int ] = None ,
404421 content_type : Optional [str ] = None ,
422+ conflict : str = "rename" ,
405423 ) -> CloudFile :
406424 if not name :
407425 raise CloudProviderError ("A file name is required for Google Drive uploads" )
@@ -490,6 +508,104 @@ def list_files(self, folder_id: str | None = None) -> List[CloudFile]:
490508 )
491509 return files
492510
511+ def _item_path_url (self , path : str , extra : str = "" ) -> str :
512+ parts = split_drive_path (path )
513+ if not parts :
514+ return (
515+ f"{ self ._base_url } /root/{ extra .lstrip ('/' )} "
516+ if extra
517+ else f"{ self ._base_url } /root"
518+ )
519+ encoded = "/" .join (quote (part , safe = "" ) for part in parts )
520+ if extra :
521+ return f"{ self ._base_url } /root:/{ encoded } :/{ extra .lstrip ('/' )} "
522+ return f"{ self ._base_url } /root:/{ encoded } "
523+
524+ def _file_from_payload (self , payload : dict , * , default_name : str = "Unnamed" ) -> CloudFile :
525+ return CloudFile (
526+ id = payload .get ("id" , "" ),
527+ name = payload .get ("name" , default_name ),
528+ is_dir = "folder" in payload ,
529+ size = _safe_int (payload .get ("size" )),
530+ modified = payload .get ("lastModifiedDateTime" ),
531+ )
532+
533+ def get_item_by_path (self , path : str ) -> Optional [CloudFile ]:
534+ parts = split_drive_path (path )
535+ if not parts :
536+ return CloudFile (id = "root" , name = "root" , is_dir = True )
537+ try :
538+ response = requests .get (
539+ self ._item_path_url (path ),
540+ headers = self ._headers (),
541+ timeout = 30 ,
542+ )
543+ except requests .RequestException as exc :
544+ raise CloudProviderError (f"OneDrive request failed: { exc } " ) from exc
545+ if response .status_code == 404 :
546+ return None
547+ if response .status_code != 200 :
548+ raise CloudProviderError (
549+ f"OneDrive item lookup failed ({ response .status_code } ): { response .text [:200 ]} "
550+ )
551+ return self ._file_from_payload (response .json ())
552+
553+ def ensure_folder_path (self , path : str ) -> CloudFile :
554+ parts = split_drive_path (path )
555+ if not parts :
556+ return CloudFile (id = "root" , name = "root" , is_dir = True )
557+ existing = self .get_item_by_path ("/" .join (parts ))
558+ if existing is not None :
559+ if not existing .is_dir :
560+ raise CloudProviderError ("OneDrive path exists and is not a folder" )
561+ return existing
562+ parent_id : Optional [str ] = None
563+ current : list [str ] = []
564+ last = CloudFile (id = "root" , name = "root" , is_dir = True )
565+ for name in parts :
566+ current .append (name )
567+ found = self .get_item_by_path ("/" .join (current ))
568+ if found is not None :
569+ if not found .is_dir :
570+ raise CloudProviderError ("OneDrive path exists and is not a folder" )
571+ last = found
572+ parent_id = found .id
573+ continue
574+ last = self .ensure_folder (name , parent_id = parent_id )
575+ parent_id = last .id
576+ return last
577+
578+ def ensure_folder (self , name : str , parent_id : Optional [str ] = None ) -> CloudFile :
579+ for item in self .list_files (parent_id ):
580+ if item .is_dir and item .name == name :
581+ return item
582+ if not parent_id or parent_id == "root" :
583+ url = f"{ self ._base_url } /root/children"
584+ else :
585+ url = f"{ self ._base_url } /items/{ parent_id } /children"
586+ try :
587+ response = requests .post (
588+ url ,
589+ headers = self ._headers (),
590+ json = {
591+ "name" : name ,
592+ "folder" : {},
593+ "@microsoft.graph.conflictBehavior" : "fail" ,
594+ },
595+ timeout = 30 ,
596+ )
597+ except requests .RequestException as exc :
598+ raise CloudProviderError (f"OneDrive request failed: { exc } " ) from exc
599+ if response .status_code in (200 , 201 ):
600+ return self ._file_from_payload (response .json (), default_name = name )
601+ if response .status_code == 409 :
602+ for item in self .list_files (parent_id ):
603+ if item .is_dir and item .name == name :
604+ return item
605+ raise CloudProviderError (
606+ f"OneDrive folder create failed ({ response .status_code } ): { response .text [:200 ]} "
607+ )
608+
493609 def download_file (self , file_id : str ) -> CloudDownload :
494610 try :
495611 meta_resp = requests .get (
@@ -559,17 +675,10 @@ def _upload_simple_onedrive(
559675 f"OneDrive upload failed ({ response .status_code } ): { response .text [:200 ]} "
560676 )
561677
562- payload = response .json ()
563- return CloudFile (
564- id = payload .get ("id" , "" ),
565- name = payload .get ("name" , name ),
566- is_dir = "folder" in payload ,
567- size = _safe_int (payload .get ("size" )),
568- modified = payload .get ("lastModifiedDateTime" ),
569- )
678+ return self ._file_from_payload (response .json (), default_name = name )
570679
571680 def _upload_chunked_onedrive (
572- self , stream , name , parent_id , mime_type , size
681+ self , stream , name , parent_id , mime_type , size , conflict : str = "rename"
573682 ) -> CloudFile :
574683 safe_name = quote (name , safe = "" )
575684 if not parent_id or parent_id == "root" :
@@ -584,7 +693,11 @@ def _upload_chunked_onedrive(
584693 session_url ,
585694 headers = self ._headers (),
586695 json = {
587- "item" : {"@microsoft.graph.conflictBehavior" : "rename" }
696+ "item" : {
697+ "@microsoft.graph.conflictBehavior" : conflict
698+ if conflict in {"rename" , "replace" , "fail" }
699+ else "rename"
700+ }
588701 },
589702 timeout = 60 ,
590703 )
@@ -629,14 +742,7 @@ def _upload_chunked_onedrive(
629742 ) from exc
630743
631744 if upload_resp .status_code in (200 , 201 ):
632- payload = upload_resp .json ()
633- return CloudFile (
634- id = payload .get ("id" , "" ),
635- name = payload .get ("name" , name ),
636- is_dir = "folder" in payload ,
637- size = _safe_int (payload .get ("size" )),
638- modified = payload .get ("lastModifiedDateTime" ),
639- )
745+ return self ._file_from_payload (upload_resp .json (), default_name = name )
640746
641747 if upload_resp .status_code in (202 , 204 ):
642748 offset = end + 1
@@ -656,6 +762,7 @@ def upload_file(
656762 parent_id : Optional [str ] = None ,
657763 size : Optional [int ] = None ,
658764 content_type : Optional [str ] = None ,
765+ conflict : str = "rename" ,
659766 ) -> CloudFile :
660767 if not name :
661768 raise CloudProviderError ("A file name is required for OneDrive uploads" )
@@ -677,7 +784,32 @@ def upload_file(
677784 return self ._upload_simple_onedrive (
678785 stream , name , parent_id , headers , mime_type
679786 )
680- return self ._upload_chunked_onedrive (stream , name , parent_id , mime_type , size )
787+ return self ._upload_chunked_onedrive (
788+ stream , name , parent_id , mime_type , size , conflict = conflict
789+ )
790+
791+ def upload_file_at_path (
792+ self ,
793+ stream : BinaryIO ,
794+ * ,
795+ drive_path : str ,
796+ size : Optional [int ] = None ,
797+ content_type : Optional [str ] = None ,
798+ conflict : str = "replace" ,
799+ ) -> CloudFile :
800+ parts = split_drive_path (drive_path )
801+ if not parts :
802+ raise CloudProviderError ("A file path is required for OneDrive uploads" )
803+ parent = self .ensure_folder_path ("/" .join (parts [:- 1 ])) if len (parts ) > 1 else None
804+ parent_id = None if parent is None or parent .id in ("" , "root" ) else parent .id
805+ return self .upload_file (
806+ stream ,
807+ name = parts [- 1 ],
808+ parent_id = parent_id ,
809+ size = size ,
810+ content_type = content_type ,
811+ conflict = conflict ,
812+ )
681813
682814
683815def encode_identifier (identifier : str ) -> str :
0 commit comments