1212# See the License for the specific language governing permissions and
1313# limitations under the License.
1414
15+ import enum
1516import re
1617from pathlib import Path
1718from typing import Optional , Union
2526logger = logging .getLogger (__name__ )
2627
2728
29+ class AvailabilityStatus (enum .Enum ):
30+ """Outcome of a URI availability check."""
31+
32+ AVAILABLE = "available"
33+ UNAVAILABLE = "unavailable"
34+ UNAUTHORIZED = "unauthorized"
35+ UNCHECKABLE = "uncheckable"
36+
37+
2838# RFC 3986 §3.1: scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
2939# Require length >= 2 to disambiguate from Windows drive letters
3040# (e.g. ``C:\path``). RFC 3986 allows single-character schemes but no
@@ -67,18 +77,62 @@ def is_external_reference(value: object) -> bool:
6777
6878
6979class URI :
70-
71- REMOTE_SUPPORTED_SCHEMA = ('http' , 'https' , 'ftp' )
80+ # Schemes that the validator can fetch natively to verify availability.
81+ # Anything outside this set is treated as remote but un-checkable.
82+ NATIVELY_CHECKABLE_SCHEMES = ("http" , "https" )
83+
84+ # Schemes accepted as RO-Crate root URIs (the loading code can only
85+ # handle these as crate locations).
86+ SUPPORTED_ROCRATE_SCHEMES = ("http" , "https" , "ftp" , "file" )
87+
88+ # Well-known remote schemes commonly used to reference data resources
89+ # (used to distinguish "recognized but un-checkable" from "unknown").
90+ KNOWN_REMOTE_SCHEMES = (
91+ # Web
92+ "http" ,
93+ "https" ,
94+ # FTP family
95+ "ftp" ,
96+ "ftps" ,
97+ "sftp" ,
98+ # Remote shell / transfer
99+ "scp" ,
100+ "ssh" ,
101+ "rsync" ,
102+ # Cloud object stores
103+ "s3" ,
104+ "gs" ,
105+ "abfs" ,
106+ "abfss" ,
107+ "wasb" ,
108+ "wasbs" ,
109+ # WebDAV
110+ "dav" ,
111+ "davs" ,
112+ # Research / big-data filesystems
113+ "irods" ,
114+ "hdfs" ,
115+ )
116+
117+ # Backwards-compatible alias kept for callers that still inspect it.
118+ REMOTE_SUPPORTED_SCHEMA = SUPPORTED_ROCRATE_SCHEMES [:- 1 ] # http, https, ftp
72119
73120 def __init__ (self , uri : Union [str , Path ]):
121+ if uri is None or (isinstance (uri , str ) and not uri .strip ()):
122+ raise ValueError ("Invalid URI: empty value" )
74123 self ._uri = uri = str (uri )
75124 try :
76- # map local path to URI with file scheme
77- if not re .match (r'^\w+://' , uri ):
125+ # Inputs that are not external references are assumed to be local
126+ # paths, so the ``file://`` scheme is added explicitly. The
127+ # detection covers both authority-based schemes (``http://``,
128+ # ``scp://``) and scheme-only ones (``urn:``, ``doi:``), as
129+ # defined by RFC 3986.
130+ if not is_external_reference (uri ):
78131 uri = f"file://{ uri } "
79132 # parse the value to extract the scheme
80133 self ._parse_result = urlparse (uri )
81- assert self .scheme in self .REMOTE_SUPPORTED_SCHEMA + ('file' ,), "Invalid URI scheme"
134+ if not self .scheme :
135+ raise ValueError ("URI has no scheme" )
82136 except Exception as e :
83137 if logger .isEnabledFor (logging .DEBUG ):
84138 logger .debug (e )
@@ -127,28 +181,70 @@ def as_path(self) -> Path:
127181 return Path (self ._uri )
128182
129183 def is_remote_resource (self ) -> bool :
130- return self .scheme in self .REMOTE_SUPPORTED_SCHEMA
184+ """Return True for any well-formed URI whose scheme is not `file`."""
185+ return bool (self .scheme ) and self .scheme != "file"
131186
132187 def is_local_resource (self ) -> bool :
133- return not self .is_remote_resource ()
188+ return self .scheme == "file"
189+
190+ def is_natively_checkable (self ) -> bool :
191+ """Return True if availability can be verified via a native request."""
192+ return self .scheme in self .NATIVELY_CHECKABLE_SCHEMES
193+
194+ def is_known_remote_scheme (self ) -> bool :
195+ """Return True if the scheme is one of the well-known remote schemes."""
196+ return self .scheme in self .KNOWN_REMOTE_SCHEMES
197+
198+ def has_supported_rocrate_scheme (self ) -> bool :
199+ """Return True if the scheme is supported as an RO-Crate root URI."""
200+ return self .scheme in self .SUPPORTED_ROCRATE_SCHEMES
134201
135202 def is_local_directory (self ) -> bool :
136203 return self .is_local_resource () and self .as_path ().is_dir ()
137204
138205 def is_local_file (self ) -> bool :
139206 return self .is_local_resource () and self .as_path ().is_file ()
140207
141- def is_available (self ) -> bool :
142- """Check if the resource is available"""
208+ def check_availability (self ) -> AvailabilityStatus :
209+ """
210+ Inspect the resource availability with as much detail as possible.
211+
212+ Distinguishes:
213+ - AVAILABLE: confirmed reachable
214+ - UNAUTHORIZED: reachable but protected (HTTP 401/403)
215+ - UNAVAILABLE: confirmed not reachable
216+ - UNCHECKABLE: scheme has no native check (e.g. scp://, s3://)
217+ """
143218 if self .is_remote_resource ():
219+ if not self .is_natively_checkable ():
220+ logger .debug (
221+ "Cannot natively verify availability for URI '%s' (scheme '%s')" ,
222+ self ._uri ,
223+ self .scheme ,
224+ )
225+ return AvailabilityStatus .UNCHECKABLE
144226 try :
145227 response = HttpRequester ().head (self ._uri , allow_redirects = True )
146- return response .status_code in (200 , 302 )
228+ if response .status_code in (200 , 302 ):
229+ return AvailabilityStatus .AVAILABLE
230+ if response .status_code in (401 , 403 ):
231+ return AvailabilityStatus .UNAUTHORIZED
232+ return AvailabilityStatus .UNAVAILABLE
147233 except Exception as e :
148234 if logger .isEnabledFor (logging .DEBUG ):
149235 logger .debug (e )
150- return False
151- return Path (self ._uri ).exists ()
236+ return AvailabilityStatus .UNAVAILABLE
237+ return AvailabilityStatus .AVAILABLE if Path (self ._uri ).exists () else AvailabilityStatus .UNAVAILABLE
238+
239+ def is_available (self ) -> bool :
240+ """
241+ Return True only when the resource is confirmed available.
242+
243+ Resources that cannot be verified (unsupported scheme, auth-protected)
244+ return False here; callers that need to distinguish those cases should
245+ use :meth:`check_availability` instead.
246+ """
247+ return self .check_availability () == AvailabilityStatus .AVAILABLE
152248
153249 def __str__ (self ):
154250 return self ._uri
@@ -179,6 +275,9 @@ def validate_rocrate_uri(uri: Union[str, Path, URI], silent: bool = False) -> bo
179275 try :
180276 # parse the value to extract the scheme
181277 uri = URI (str (uri )) if isinstance (uri , str ) or isinstance (uri , Path ) else uri
278+ # restrict RO-Crate roots to schemes the loader can actually handle
279+ if not uri .has_supported_rocrate_scheme ():
280+ raise errors .ROCrateInvalidURIError (uri )
182281 # check if the URI is a remote resource or local directory or local file
183282 if not uri .is_remote_resource () and not uri .is_local_directory () and not uri .is_local_file ():
184283 raise errors .ROCrateInvalidURIError (uri )
@@ -187,7 +286,7 @@ def validate_rocrate_uri(uri: Union[str, Path, URI], silent: bool = False) -> bo
187286 raise errors .ROCrateInvalidURIError (uri )
188287 # check if the resource is available
189288 if not uri .is_available ():
190- raise errors .ROCrateInvalidURIError (uri , message = f" The RO-crate at the URI \ "{ uri } \ " is not available" )
289+ raise errors .ROCrateInvalidURIError (uri , message = f' The RO-crate at the URI "{ uri } " is not available' )
191290 return True
192291 except ValueError as e :
193292 logger .error (e )
0 commit comments