@@ -45,6 +45,27 @@ def is_url(path: str) -> bool:
4545 return path .lower ().startswith (URL_PROTOCOLS )
4646
4747
48+ def _path_to_file_url (resolved_path : Path | PurePosixPath ) -> str :
49+ """
50+ Convert an already-resolved absolute path to a ``file://`` URL.
51+
52+ Uses ``as_posix()`` so the same logic handles both POSIX paths (which
53+ already start with ``/``) and Windows paths (``C:/...``, no leading
54+ slash) without OS-specific branching.
55+
56+ Parameters
57+ ----------
58+ resolved_path : Path or PurePosixPath
59+ Absolute, already-resolved path.
60+
61+ Returns
62+ -------
63+ str
64+ ``file://`` URL.
65+ """
66+ return f"file:///{ resolved_path .as_posix ().lstrip ('/' )} "
67+
68+
4869def normalize_to_url (path : str ) -> str :
4970 """
5071 Normalize a path to URL form.
@@ -72,15 +93,7 @@ def normalize_to_url(path: str) -> str:
7293 """
7394 if is_url (path ):
7495 return path
75- # Convert local path to file:// URL
76- # Ensure absolute path and proper format
77- abs_path = str (Path (path ).resolve ())
78- # Handle Windows paths (C:\...) vs Unix paths (/...)
79- if abs_path .startswith ("/" ):
80- return f"file://{ abs_path } "
81- else :
82- # Windows: file:///C:/path
83- return f"file:///{ abs_path .replace (chr (92 ), '/' )} "
96+ return _path_to_file_url (Path (path ).resolve ())
8497
8598
8699def parse_url (url : str ) -> tuple [str , str ]:
@@ -327,10 +340,21 @@ def _validate_spec(self):
327340 if location and not Path (location ).is_dir ():
328341 raise FileNotFoundError (f"Inaccessible local directory { location } " )
329342 elif self .protocol == "s3" :
330- required = ["endpoint" , "bucket" , "access_key" , "secret_key" ]
343+ required = ["endpoint" , "bucket" ]
331344 missing = [k for k in required if not self .spec .get (k )]
332345 if missing :
333346 raise errors .DataJointError (f"Missing S3 configuration: { ', ' .join (missing )} " )
347+ # access_key/secret_key are optional: when both are absent the
348+ # underlying botocore credential chain resolves an ambient identity
349+ # (instance profile, IRSA, ECS task role, SSO), matching gcs/azure.
350+ # But botocore treats exactly one as a partial credential and fails
351+ # late (PartialCredentialsError at first access), so reject that here
352+ # with a clear message.
353+ if bool (self .spec .get ("access_key" )) != bool (self .spec .get ("secret_key" )):
354+ raise errors .DataJointError (
355+ "Incomplete S3 credentials: set both access_key and secret_key, "
356+ "or neither to use ambient AWS credentials."
357+ )
334358
335359 @property
336360 def fs (self ) -> fsspec .AbstractFileSystem :
@@ -363,10 +387,14 @@ def _create_filesystem(self) -> fsspec.AbstractFileSystem:
363387 else :
364388 endpoint_url = endpoint
365389
390+ # Coerce falsy (missing or empty-string) credentials to None so s3fs
391+ # drops them and botocore falls through to the default chain. A
392+ # forwarded "" is NOT equivalent: it survives s3fs's None-filter and
393+ # botocore reads it as an explicit (invalid) credential.
366394 return fsspec .filesystem (
367395 "s3" ,
368- key = self .spec [ "access_key" ] ,
369- secret = self .spec [ "secret_key" ] ,
396+ key = self .spec . get ( "access_key" ) or None ,
397+ secret = self .spec . get ( "secret_key" ) or None ,
370398 client_kwargs = {"endpoint_url" : endpoint_url },
371399 )
372400
@@ -418,7 +446,7 @@ def _full_path(self, path: str | PurePosixPath) -> str:
418446 elif self .protocol == "file" :
419447 location = self .spec .get ("location" , "" )
420448 if location :
421- return str (Path (location ) / path )
449+ return (Path (location ) / path ). as_posix ( )
422450 return path
423451 else :
424452 return self ._require_adapter ().full_path (self .spec , path )
@@ -453,13 +481,7 @@ def get_url(self, path: str | PurePosixPath) -> str:
453481 full_path = self ._full_path (path )
454482
455483 if self .protocol == "file" :
456- # Ensure absolute path for file:// URL
457- abs_path = str (Path (full_path ).resolve ())
458- if abs_path .startswith ("/" ):
459- return f"file://{ abs_path } "
460- else :
461- # Windows path
462- return f"file:///{ abs_path .replace (chr (92 ), '/' )} "
484+ return _path_to_file_url (Path (full_path ).resolve ())
463485 elif self .protocol == "s3" :
464486 return f"s3://{ full_path } "
465487 elif self .protocol == "gcs" :
0 commit comments