11from typing import Literal , List , Tuple , Optional , Union
2+ from urllib3 .fields import RequestField
23import logging
34import os
45import weakref
@@ -238,6 +239,105 @@ def validate_integration_type(integration_type: str) -> IntegrationType:
238239 raise ValueError (f"Invalid integration type: { integration_type } " )
239240 return integration_type # type: ignore
240241
242+ @staticmethod
243+ def calculate_upload_key (file_path : str , workspace : Optional [str ] = None , base_path : Optional [str ] = None , base_paths : Optional [List [str ]] = None ) -> str :
244+ """Calculate the repo-relative multipart field name for a manifest."""
245+ workspace , base_path , base_paths = Utils ._normalize_upload_options (workspace , base_path , base_paths )
246+ return Utils ._calculate_normalized_upload_key (file_path .replace ("\\ " , "/" ), workspace , base_path , base_paths )
247+
248+ @staticmethod
249+ def _normalize_upload_options (workspace , base_path , base_paths ):
250+ return (
251+ workspace .replace ("\\ " , "/" ) if workspace else workspace ,
252+ base_path .replace ("\\ " , "/" ) if base_path else base_path ,
253+ [path .replace ("\\ " , "/" ) for path in base_paths ] if base_paths else base_paths ,
254+ )
255+
256+ @staticmethod
257+ def _calculate_normalized_upload_key (file_path , workspace , base_path , base_paths ):
258+ # Calculate the key name for the form data
259+ key = file_path
260+ path_stripped = False
261+
262+ # If base_paths is provided, try to strip one of the paths from the file path
263+ if base_paths :
264+ for bp in base_paths :
265+ normalized_base_path = bp .rstrip ("/" ) + "/" if not bp .endswith ("/" ) else bp
266+ if key .startswith (normalized_base_path ):
267+ key = key [len (normalized_base_path ):]
268+ path_stripped = True
269+ break
270+ elif key .startswith (bp .rstrip ("/" )):
271+ stripped_base = bp .rstrip ("/" )
272+ if key .startswith (stripped_base + "/" ) or key == stripped_base :
273+ key = key [len (stripped_base ):]
274+ key = key .lstrip ("/" )
275+ path_stripped = True
276+ break
277+ elif base_path :
278+ normalized_base_path = base_path .rstrip ("/" ) + "/" if not base_path .endswith ("/" ) else base_path
279+ if key .startswith (normalized_base_path ):
280+ key = key [len (normalized_base_path ):]
281+ path_stripped = True
282+ elif key .startswith (base_path .rstrip ("/" )):
283+ stripped_base = base_path .rstrip ("/" )
284+ if key .startswith (stripped_base + "/" ) or key == stripped_base :
285+ key = key [len (stripped_base ):]
286+ key = key .lstrip ("/" )
287+ path_stripped = True
288+
289+ # If workspace is provided and no base paths matched, fall back to workspace logic
290+ if not path_stripped and workspace and file_path .startswith (workspace ):
291+ key = file_path [len (workspace ):]
292+ # Remove all leading slashes (for absolute paths)
293+ while key .startswith ("/" ):
294+ key = key [1 :]
295+ path_stripped = True
296+
297+ # Clean up relative path prefixes, but preserve filename dots
298+ while key .startswith ("./" ):
299+ key = key [2 :]
300+ while key .startswith ("../" ):
301+ key = key [3 :]
302+ # Remove any remaining leading slashes (for absolute paths)
303+ while key .startswith ("/" ):
304+ key = key [1 :]
305+
306+ # Remove Windows drive letter if present (C:/...)
307+ if len (key ) > 2 and key [1 ] == ':' and (key [2 ] == '/' or key [2 ] == '\\ ' ):
308+ key = key [2 :]
309+ while key .startswith ("/" ):
310+ key = key [1 :]
311+
312+ return key
313+
314+ @staticmethod
315+ def validate_upload_keys (key_sources : List [Tuple [str , str ]]) -> None :
316+ """Reject colliding multipart keys, preserving source paths for diagnostics."""
317+ seen = {}
318+ formatter = RequestField (name = "" , data = None ).header_formatter
319+ for key , source in key_sources :
320+ if key is None :
321+ continue
322+ # Use the installed encoder's escaping rules; the result is name="<key>".
323+ wire_key = formatter ("name" , key )[6 :- 1 ]
324+ if wire_key in seen :
325+ raise ValueError (
326+ f"Duplicate upload key '{ wire_key } ' from '{ seen [wire_key ]} ' and '{ source } '"
327+ )
328+ seen [wire_key ] = source
329+
330+ @staticmethod
331+ def prepare_upload_keys (files : List [str ], workspace : Optional [str ] = None , base_path : Optional [str ] = None , base_paths : Optional [List [str ]] = None ) -> List [Tuple [str , str ]]:
332+ """Compute and validate all keys before reading any file contents."""
333+ workspace , base_path , base_paths = Utils ._normalize_upload_options (workspace , base_path , base_paths )
334+ key_sources = [
335+ (Utils ._calculate_normalized_upload_key (path .replace ("\\ " , "/" ), workspace , base_path , base_paths ), path )
336+ for path in files
337+ ]
338+ Utils .validate_upload_keys (key_sources )
339+ return key_sources
340+
241341 @staticmethod
242342 def load_files_for_sending_lazy (files : List [str ], workspace : Optional [str ] = None , max_open_files : int = 100 , base_path : Optional [str ] = None , base_paths : Optional [List [str ]] = None ) -> List [Tuple [str , Tuple [str , LazyFileLoader ]]]:
243343 """
@@ -258,86 +358,19 @@ def load_files_for_sending_lazy(files: List[str], workspace: Optional[str] = Non
258358 Returns:
259359 List of tuples formatted for requests multipart upload:
260360 [(field_name, (filename, lazy_file_object)), ...]
361+
362+ Raises:
363+ ValueError: If two source paths produce the same upload key.
261364 """
262365 # Configure the file descriptor manager with the specified limit
263366 _fd_manager .set_max_open_files (max_open_files )
264367
265368 send_files = []
266- if workspace and "\\ " in workspace :
267- workspace = workspace .replace ("\\ " , "/" )
268- if base_path and "\\ " in base_path :
269- base_path = base_path .replace ("\\ " , "/" )
270- if base_paths :
271- base_paths = [bp .replace ("\\ " , "/" ) if "\\ " in bp else bp for bp in base_paths ]
272-
273- for file_path in files :
274- # Normalize file path
275- if "\\ " in file_path :
276- file_path = file_path .replace ("\\ " , "/" )
277-
278- # Skip directories
279- if os .path .isdir (file_path ):
280- continue
281-
282- # Handle file path splitting safely
283- if "/" in file_path :
284- _ , name = file_path .rsplit ("/" , 1 )
285- else :
286- name = file_path
287-
288- # Calculate the key name for the form data
289- key = file_path
290- path_stripped = False
291-
292- # If base_paths is provided, try to strip one of the paths from the file path
293- if base_paths :
294- for bp in base_paths :
295- normalized_base_path = bp .rstrip ("/" ) + "/" if not bp .endswith ("/" ) else bp
296- if key .startswith (normalized_base_path ):
297- key = key [len (normalized_base_path ):]
298- path_stripped = True
299- break
300- elif key .startswith (bp .rstrip ("/" )):
301- stripped_base = bp .rstrip ("/" )
302- if key .startswith (stripped_base + "/" ) or key == stripped_base :
303- key = key [len (stripped_base ):]
304- key = key .lstrip ("/" )
305- path_stripped = True
306- break
307- elif base_path :
308- normalized_base_path = base_path .rstrip ("/" ) + "/" if not base_path .endswith ("/" ) else base_path
309- if key .startswith (normalized_base_path ):
310- key = key [len (normalized_base_path ):]
311- path_stripped = True
312- elif key .startswith (base_path .rstrip ("/" )):
313- stripped_base = base_path .rstrip ("/" )
314- if key .startswith (stripped_base + "/" ) or key == stripped_base :
315- key = key [len (stripped_base ):]
316- key = key .lstrip ("/" )
317- path_stripped = True
318-
319- # If workspace is provided and no base paths matched, fall back to workspace logic
320- if not path_stripped and workspace and file_path .startswith (workspace ):
321- key = file_path [len (workspace ):]
322- # Remove all leading slashes (for absolute paths)
323- while key .startswith ("/" ):
324- key = key [1 :]
325- path_stripped = True
326-
327- # Clean up relative path prefixes, but preserve filename dots
328- while key .startswith ("./" ):
329- key = key [2 :]
330- while key .startswith ("../" ):
331- key = key [3 :]
332- # Remove any remaining leading slashes (for absolute paths)
333- while key .startswith ("/" ):
334- key = key [1 :]
335-
336- # Remove Windows drive letter if present (C:/...)
337- if len (key ) > 2 and key [1 ] == ':' and (key [2 ] == '/' or key [2 ] == '\\ ' ):
338- key = key [2 :]
339- while key .startswith ("/" ):
340- key = key [1 :]
369+ # Directories do not produce multipart parts, so exclude them before validating keys.
370+ upload_files = [path for path in files if not os .path .isdir (path .replace ("\\ " , "/" ))]
371+ key_sources = Utils .prepare_upload_keys (upload_files , workspace , base_path , base_paths )
372+ for key , source_path in key_sources :
373+ file_path = source_path .replace ("\\ " , "/" )
341374
342375 # Create lazy file loader instead of opening file immediately
343376 lazy_file = LazyFileLoader (file_path , key )
0 commit comments