Skip to content

Commit 469bbe5

Browse files
committed
feat(uri): ✨ classify remote schemes and report granular availability
Replace the single REMOTE_SUPPORTED_SCHEMA tuple with purpose-specific scheme sets (natively-checkable, supported RO-Crate roots, known remote) and add AvailabilityStatus to distinguish AVAILABLE, UNAVAILABLE, UNAUTHORIZED and UNCHECKABLE outcomes.
1 parent 62f89c0 commit 469bbe5

1 file changed

Lines changed: 112 additions & 13 deletions

File tree

rocrate_validator/utils/uri.py

Lines changed: 112 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import enum
1516
import re
1617
from pathlib import Path
1718
from typing import Optional, Union
@@ -25,6 +26,15 @@
2526
logger = 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

6979
class 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

Comments
 (0)